summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 14:53:46 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit12c257e624d3f1f80ca3df041b54026ff772d638 (patch)
tree6bba52da1148e167ed695e219bb3a9c742337d95
parente56a8ba396566eaab818b6c742850b5842e880bb (diff)
downloadpardes-12c257e624d3f1f80ca3df041b54026ff772d638.tar.gz
pardes-12c257e624d3f1f80ca3df041b54026ff772d638.zip
A path typed with a leading ~ is the home directory, at every entry point that takes one, through one helper
Only DumpDir (dump.zig) and ShaderBuild expanded `~/`, each its own way. So a look at `~/notes.txt`, `name ~/x`, `Save ~/x`, ThemeFile, Restore and a quoted `pardes '~/x'` all took `~` as a directory of that name under the pane's. There is now one rule, filesystem.expandHome. A leading `~`, alone or before `/` or `:`, is $HOME, else the passwd entry's home; `~user` is that user's (getpwnam). As in a shell, it applies even beside a file named `~`, and `./~` names that file. Every entry point runs its typed path through it: - look, B3 and 9P, before any parsing, so `~/x:12`, `~/x:12:3` and `~/x:/re/` all work; - name, and a tag's rename; - Save <path>; - ThemeFile, DumpDir, Restore and ShaderBuild's shader files; - the forwarding launch. Recent rows are not shown with `~` and need nothing. How names are shown is unchanged. Tests: expandHome's cases, and HOME unset (passwd), with HOME passed in rather than set, since setenv moves the environ a spawned child reads. fs.py covers Save into missing directories under `~`, `name ~/x`, a look at `~/x:3`, and a forwarded `pardes '~/x'`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md6
-rw-r--r--src/ShaderBuild.zig8
-rw-r--r--src/builtins.zig3
-rw-r--r--src/dump.zig7
-rw-r--r--src/exec.zig3
-rw-r--r--src/fs.zig57
-rw-r--r--src/look.zig5
-rw-r--r--src/main.zig7
-rw-r--r--src/ninep/pane.zig3
-rw-r--r--src/tagline.zig3
-rw-r--r--test/fs.py22
11 files changed, 105 insertions, 19 deletions
diff --git a/docs/fs.md b/docs/fs.md
index b37511dd..146ff17b 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -199,7 +199,11 @@ A line written to `look` is a right click on it:
selects line 12, newline included; `file:12:5` puts the caret at line 12,
byte column 5; `file:<addr>` takes any address (below), **evaluated from
the file's dot**: `file:/re/` finds the next match after the selection,
- `file:0/re/` the first. `:addr` addresses the pane itself.
+ `file:0/re/` the first. `:addr` addresses the pane itself. A leading `~`
+ is the home directory ($HOME, else the passwd entry's; `~user` that
+ user's) here and wherever a path is typed (`name`, `Save`, `ThemeFile`,
+ `DumpDir`, `Restore`, `pardes '~/x'`), even beside a file named `~`: write
+ `./~` for that.
- `@p<serial>:<addr>` addresses a pane by serial, a terminal's logical lines
too.
- a directory types `ls` into a terminal idle there, else opens one there.
diff --git a/src/ShaderBuild.zig b/src/ShaderBuild.zig
index c2610fd7..b50999a5 100644
--- a/src/ShaderBuild.zig
+++ b/src/ShaderBuild.zig
@@ -236,11 +236,9 @@ fn collect(b: *Build, gpa: std.mem.Allocator, core: *pardes.Pardes, job: *Job) v
}
}
-/// `~/` is the home directory.
-fn expand(buf: []u8, path: []const u8) []const u8 {
- if (!std.mem.startsWith(u8, path, "~/")) return path;
- const home = libc.getenv("HOME") orelse return path;
- return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path;
+/// `~` is the home directory (the one rule, filesystem.expandHome).
+fn expand(buf: *[4096]u8, path: []const u8) []const u8 {
+ return pardes.filesystem.expandHome(path, buf);
}
/// The compile thread: each file behind the prefix, through glslc with the
diff --git a/src/builtins.zig b/src/builtins.zig
index ba67766d..6aabba8d 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -818,7 +818,8 @@ pub const ThemeFile = struct {
pub const enabled = pardes.hosted;
pub fn run(c: Ctx) void {
if (comptime !enabled) unreachable;
- const arg = std.mem.trim(u8, c.arg orelse return, " \t\r\n");
+ var home_buf: [4096]u8 = undefined;
+ const arg = pardes.filesystem.expandHome(std.mem.trim(u8, c.arg orelse return, " \t\r\n"), &home_buf);
// Said with why, not "invalid theme path".
if (!std.mem.endsWith(u8, arg, ".zon")) {
var said: [320]u8 = undefined;
diff --git a/src/dump.zig b/src/dump.zig
index 1cc115be..8e767240 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -100,13 +100,10 @@ fn hostEnv(name: [*:0]const u8) ?[*:0]u8 {
/// setting when set (a leading `~/` is the home directory), else the default.
pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
if (dir_setting.len == 0) return defaultDirectory(buf);
- var joined: [2048]u8 = undefined;
+ var joined: [4096]u8 = undefined;
// `~/x`, else a relative one from the directory pardes started in: the
// same place however it was written, and shown so.
- const raw = if (std.mem.startsWith(u8, dir_setting, "~/")) (if (hostEnv("HOME")) |h|
- std.fmt.bufPrint(&joined, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch return null
- else
- dir_setting) else dir_setting;
+ const raw = pardes.filesystem.expandHome(dir_setting, &joined);
var fba_buf: [4096]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&fba_buf);
var cwd_buf: [1024]u8 = undefined;
diff --git a/src/exec.zig b/src/exec.zig
index 7d682d74..d114dbf8 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -295,9 +295,10 @@ pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void {
if (path.len == 0 or path[path.len - 1] == '/' or
std.mem.indexOfScalar(u8, path, '\n') != null)
return p.reportError(id, "save", error.NoFilename);
+ var home_buf: [4096]u8 = undefined;
const full = std.fs.path.resolvePosix(
p.scratch.allocator(),
- &.{ Pardes.paneDir(pane), path },
+ &.{ Pardes.paneDir(pane), @import("fs.zig").expandHome(path, &home_buf) },
) catch return;
if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory);
if (full.len > Pardes.SavePath.cap) return p.reportError(id, "save", error.PathTooLong);
diff --git a/src/fs.zig b/src/fs.zig
index 30ad9c43..7695d497 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -732,6 +732,59 @@ pub fn dotRelative(word: []const u8) bool {
/// Where a path really is on the host, and whether it is a directory. The
/// path may lie in a mount this editor serves, so the turn is given up for
/// the syscalls: another thread answers them.
+/// A path's leading `~` -- alone, or before `/` or `:` (`~/x:12`) -- is the
+/// home directory: $HOME, else the passwd entry's. `~user/...` is that
+/// user's. As in a shell, `~` is home even beside a file named `~` in the
+/// current directory. Every entry point that takes a path the user typed
+/// runs it through here: look (B3 and 9P), name, Save, ThemeFile, DumpDir,
+/// Restore, a forwarded `pardes ~/x`. The path itself when there is no
+/// `~` to expand, or no home for it.
+pub fn expandHome(path: []const u8, out: *[4096]u8) []const u8 {
+ if (comptime !pardes.hosted) return path;
+ return expandHomeFrom(path, out, std.c.getenv("HOME"));
+}
+
+/// expandHome with HOME given (null: unset), so a test need not change the
+/// process's environment, which a child spawned later reads.
+fn expandHomeFrom(path: []const u8, out: *[4096]u8, env_home: ?[*:0]const u8) []const u8 {
+ if (path.len == 0 or path[0] != '~') return path;
+ const end = std.mem.indexOfAny(u8, path, "/:") orelse path.len;
+ const home = (if (end == 1) homeDir(env_home) else userHome(path[1..end])) orelse return path;
+ const trimmed = std.mem.trimEnd(u8, home, "/");
+ const rest = path[end..];
+ if (trimmed.len == 0 and (rest.len == 0 or rest[0] != '/')) return std.fmt.bufPrint(out, "/{s}", .{rest}) catch path;
+ return std.fmt.bufPrint(out, "{s}{s}", .{ trimmed, rest }) catch path;
+}
+
+fn homeDir(env_home: ?[*:0]const u8) ?[]const u8 {
+ if (env_home) |h| if (h[0] != 0) return std.mem.span(h);
+ const pw = std.c.getpwuid(std.c.getuid()) orelse return null;
+ return std.mem.span(pw.dir orelse return null);
+}
+
+fn userHome(name: []const u8) ?[]const u8 {
+ var z: [256]u8 = undefined;
+ const name_z = std.fmt.bufPrintSentinel(&z, "{s}", .{name}, 0) catch return null;
+ const pw = std.c.getpwnam(name_z) orelse return null;
+ return std.mem.span(pw.dir orelse return null);
+}
+
+test "a leading ~ is home, from HOME or else the passwd entry" {
+ var out: [4096]u8 = undefined;
+ const home = homeDir(std.c.getenv("HOME")) orelse return error.SkipZigTest;
+ var want: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&want, "{s}/x:12", .{std.mem.trimEnd(u8, home, "/")}), expandHome("~/x:12", &out));
+ try std.testing.expectEqualStrings(std.mem.trimEnd(u8, home, "/"), expandHome("~", &out));
+ try std.testing.expectEqualStrings("a/~/b", expandHome("a/~/b", &out));
+ try std.testing.expectEqualStrings("~no-such-user-pardes/x", expandHome("~no-such-user-pardes/x", &out));
+ try std.testing.expectEqualStrings("/", expandHome("~root", &out)[0..1]);
+
+ // HOME unset: the passwd entry's home.
+ const pw = std.c.getpwuid(std.c.getuid()) orelse return error.SkipZigTest;
+ var from_passwd: [4096]u8 = undefined;
+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&from_passwd, "{s}/y", .{std.mem.trimEnd(u8, std.mem.span(pw.dir.?), "/")}), expandHomeFrom("~/y", &out, null));
+}
+
pub fn resolveOs(path: []const u8, out: *[4096]u8) ?Resolved {
if (comptime !platform_has_fs) return null;
var z: [4096]u8 = undefined;
@@ -1171,7 +1224,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
/// Restore first tries a relative argument under the default dump directory,
/// then preserves the ordinary file lookup. Absolute paths remain verbatim.
-pub fn readRestore(gpa: std.mem.Allocator, path: []const u8, dir_setting: []const u8) ![]u8 {
+pub fn readRestore(gpa: std.mem.Allocator, typed: []const u8, dir_setting: []const u8) ![]u8 {
+ var home_buf: [4096]u8 = undefined;
+ const path = expandHome(typed, &home_buf);
var dir_buf: [4096]u8 = undefined;
const dir = @import("dump.zig").directory(&dir_buf, dir_setting);
return readRestoreIn(gpa, path, dir);
diff --git a/src/look.zig b/src/look.zig
index de51804d..357a9c63 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1499,8 +1499,11 @@ fn isRecentList(pane: *const Pane) bool {
return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist });
}
-pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
+pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
const pane = p.panes[id] orelse return;
+ // `~/x`, `~/x:12`, `~/x:/re/`: home, before anything reads the text.
+ var home_buf: [4096]u8 = undefined;
+ const operand = filesystem.expandHome(std.mem.trim(u8, operand_raw, " \t\r\n"), &home_buf);
// A +Recent row is a remembered place: reopened and clamped to the file
// as it is now, never a miss for a line the file no longer has.
if (isRecentList(pane)) {
diff --git a/src/main.zig b/src/main.zig
index 576efc8e..dfeed52d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -407,7 +407,7 @@ fn nativeMain(init: std.process.Init) !void {
// itself, which is what a right click in that pane would have been.
var look_buf: [64]u8 = undefined;
const look = try std.fmt.bufPrint(&look_buf, "/pane/{d}/look", .{parent.serial});
- const word = positional orelse {
+ const typed_word = positional orelse {
var tag_buf: [64]u8 = undefined;
const tag = try std.fmt.bufPrint(&tag_buf, "/pane/{d}/tag", .{parent.serial});
const contents = ninep_io.Client.read(arena, parent.dial, tag, tag) catch |err| Refuse.with(init.io, tag, err);
@@ -415,7 +415,10 @@ fn nativeMain(init: std.process.Init) !void {
try std.Io.File.stderr().writeStreamingAll(init.io, nested_text);
std.process.exit(1);
};
- if (std.mem.indexOfAny(u8, word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine);
+ if (std.mem.indexOfAny(u8, typed_word, "\r\n") != null) Refuse.with(init.io, "a file name", error.NotOneLine);
+ // `pardes '~/x'`, quoted so the shell left it: home, as a look's.
+ var home_buf: [4096]u8 = undefined;
+ const word = pardes.filesystem.expandHome(typed_word, &home_buf);
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
var newbuf: [4096]u8 = undefined;
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index dc8b4b10..7526075b 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -860,7 +860,8 @@ fn writeName(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
"rename not allowed: a terminal is named by its shell's directory; cd there, or Tty in another"
else
"rename not allowed: an image or PDF is named by the file it shows");
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), name }) catch
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ pardes.Pardes.paneDir(pane), fs.expandHome(name, &home_buf) }) catch
return Reply.fail(req.tag, E.NOMEM);
if (!std.fs.path.isAbsolute(full) or full.len >= 4096) return tree.failText(req.tag, E.INVAL, "invalid file name: longer than a path may be");
nameBuffer(p, id, full, false) catch |err| return Reply.fail(req.tag, switch (err) {
diff --git a/src/tagline.zig b/src/tagline.zig
index 1923871a..dc7d10d4 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -344,7 +344,8 @@ pub fn commitTagName(p: *Pardes, pane: *Pane) bool {
p.reportError(id, "name", error.InvalidName);
return false;
};
- const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), name }) catch |err| {
+ var home_buf: [4096]u8 = undefined;
+ const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ Pardes.paneDir(pane), pardes.filesystem.expandHome(name, &home_buf) }) catch |err| {
p.reportError(id, "name", err);
return false;
};
diff --git a/test/fs.py b/test/fs.py
index b02b9e45..55ec3257 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -383,6 +383,19 @@ def discovery(binary, embedded=False):
client.remove(f'/pane/{scratch}')
# A Save makes the directories its file goes in, whatever names
# it: the pane's own name or Save's path.
+ # `~` is home (this session's HOME, the test's root) wherever a
+ # path is typed: Save, name, a look, with an address or not.
+ tilde = new_pane(client, b'one\ntwo\nthree\n')
+ client.write(f'/pane/{tilde}/ctl', b'Save ~/tilde-made/sub/x.txt\n')
+ assert (root / 'tilde-made/sub/x.txt').read_bytes() == b'one\ntwo\nthree\n'
+ client.write(f'/pane/{tilde}/name', b'~/tilde-named.txt\n')
+ assert client.read(f'/pane/{tilde}/name') == f'{root}/tilde-named.txt\n'.encode()
+ client.remove(f'/pane/{tilde}')
+ client.write('/look', b'~/tilde-made/sub/x.txt:3\n')
+ looked = [row for row in client.read('/index').decode().splitlines() if row.split()[3] == f'{root}/tilde-made/sub/x.txt']
+ assert len(looked) == 1, client.read('/index')
+ assert client.read(f'/pane/{looked[0].split()[0]}/addr').startswith(b'8 '), client.read(f'/pane/{looked[0].split()[0]}/addr')
+ client.remove(f'/pane/{looked[0].split()[0]}')
made_dirs = new_pane(client, b'deep\n')
client.write(f'/pane/{made_dirs}/ctl', f'Save {root}/save-made/a/b/x.txt\n'.encode())
assert (root / 'save-made/a/b/x.txt').read_bytes() == b'deep\n'
@@ -1333,6 +1346,15 @@ def test(binary, quic=False):
execute(client, named, 'Save')
client.remove(f'/pane/{named}')
assert deep.read_bytes() == b'deep\n'
+ # `pardes '~/x'`, quoted so the shell left the `~`: home all the
+ # same: the launching shell's HOME.
+ before = serials()
+ quoted = subprocess.run([binary, '~/fwd-tilde.txt'], cwd=root, env=dict(env, HOME=str(root)),
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=10)
+ assert quoted.returncode == 0, quoted.stderr
+ assert f'{root}/fwd-tilde.txt'.encode() in client.read('/index'), client.read('/index')
+ for serial in serials() - before:
+ client.remove(f'/pane/{serial}')
# A name the session refuses: its reason and exit 1, as B's,
# never a nested editor, and no pane left for it.
before = serials()