commit bc801af9e3635c64d212ad96485a3ad15b793406
parent ca289ff53182b010eed2dfea185d5fbbdd9aba9f
Author: mtmn <miro@haravara.org>
Date: Thu, 24 Sep 2026 23:52:11 +0200
pmn: add pass edit and map to C-e
Diffstat:
3 files changed, 127 insertions(+), 18 deletions(-)
diff --git a/pmn/README.md b/pmn/README.md
@@ -18,3 +18,9 @@ zig build -Doptimize=ReleaseSafe
```
> Set `PASSWORD_STORE_DIR` if your password store is not at the default `~/.password-store` path.
+
+## Key bindings
+
+- Enter: show the highlighted password (or create one from the typed query), then copy it to the clipboard.
+- Ctrl-e: edit the highlighted password in `$EDITOR` (or create a new entry from the typed query). Exit without touching the clipboard.
+- Esc: quit without doing anything.
diff --git a/pmn/build.zig b/pmn/build.zig
@@ -13,4 +13,16 @@ pub fn build(b: *std.Build) void {
}),
});
b.installArtifact(exe);
+
+ const exe_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+
+ const run_exe_tests = b.addRunArtifact(exe_tests);
+ const test_step = b.step("test", "Run unit tests");
+ test_step.dependOn(&run_exe_tests.step);
}
diff --git a/pmn/src/main.zig b/pmn/src/main.zig
@@ -32,8 +32,18 @@ pub fn main(init: std.process.Init) !void {
try paths.append(alloc, entry.path);
}
- const selected = try fzf(init, alloc, paths.items) orelse return;
- defer alloc.free(selected);
+ const pick = try fzf(init, alloc, paths.items) orelse return;
+ defer pick.deinit(alloc);
+
+ if (std.mem.eql(u8, pick.key, "ctrl-e")) {
+ const target = if (pick.selected.len > 0) pick.selected else pick.query;
+ if (target.len == 0) return;
+ try edit(init, target);
+ return;
+ }
+
+ const selected = if (pick.selected.len > 0) pick.selected else pick.query;
+ if (selected.len == 0) return;
const exists = blk: {
for (passwords.items) |item| {
@@ -91,13 +101,13 @@ fn collect(alloc: std.mem.Allocator, io: std.Io, base_dir: []const u8, sub_path:
const accessed = blk: {
const file = std.Io.Dir.openFileAbsolute(io, file_path, .{}) catch |err| {
- std.debug.print("Failed to stat {s}: {}\n", .{ file_path, err });
+ std.log.warn("failed to open {s}: {}", .{ file_path, err });
break :blk @as(i128, 0);
};
defer file.close(io);
const file_stat = file.stat(io) catch |err| {
- std.debug.print("Failed to stat {s}: {}\n", .{ file_path, err });
+ std.log.warn("failed to stat {s}: {}", .{ file_path, err });
break :blk @as(i128, 0);
};
@@ -116,9 +126,40 @@ fn collect(alloc: std.mem.Allocator, io: std.Io, base_dir: []const u8, sub_path:
}
}
-fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const u8) !?[]u8 {
+const Pick = struct {
+ query: []const u8,
+ key: []const u8,
+ selected: []const u8,
+ raw: []u8,
+
+ fn deinit(self: Pick, alloc: std.mem.Allocator) void {
+ alloc.free(self.raw);
+ }
+};
+
+const PickParts = struct {
+ query: []const u8,
+ key: []const u8,
+ selected: []const u8,
+};
+
+fn parseFzfOutput(output: []const u8) ?PickParts {
+ var lines = [3][]const u8{ "", "", "" };
+ var it = std.mem.splitScalar(u8, output, '\n');
+ var i: usize = 0;
+ while (i < lines.len) : (i += 1) {
+ const line = it.next() orelse break;
+ lines[i] = std.mem.trim(u8, line, " \n\r\t");
+ }
+
+ if (lines[0].len == 0 and lines[1].len == 0 and lines[2].len == 0) return null;
+
+ return .{ .query = lines[0], .key = lines[1], .selected = lines[2] };
+}
+
+fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const u8) !?Pick {
var child = try std.process.spawn(init.io, .{
- .argv = &.{ "fzf", "--exact", "--print-query" },
+ .argv = &.{ "fzf", "--exact", "--print-query", "--expect=ctrl-e" },
.stdin = .pipe,
.stdout = .pipe,
.stderr = .inherit,
@@ -137,22 +178,72 @@ fn fzf(init: std.process.Init, alloc: std.mem.Allocator, items: []const []const
}
const output = try read(init.io, alloc, &child);
- defer alloc.free(output);
+ errdefer alloc.free(output);
const term = try child.wait(init.io);
- if (term != .exited) return null;
+ if (term != .exited) {
+ alloc.free(output);
+ return null;
+ }
- const trimmed = std.mem.trim(u8, output, " \n\r\t");
- if (trimmed.len == 0) return null;
+ const parts = parseFzfOutput(output) orelse {
+ alloc.free(output);
+ return null;
+ };
- if (std.mem.findScalar(u8, trimmed, '\n')) |idx| {
- const selected = std.mem.trim(u8, trimmed[idx + 1 ..], " \n\r\t");
- if (selected.len > 0) return try alloc.dupe(u8, selected);
- const query = std.mem.trim(u8, trimmed[0..idx], " \n\r\t");
- if (query.len > 0) return try alloc.dupe(u8, query);
- }
+ return .{
+ .query = parts.query,
+ .key = parts.key,
+ .selected = parts.selected,
+ .raw = output,
+ };
+}
+
+fn edit(init: std.process.Init, target: []const u8) !void {
+ var child = try std.process.spawn(init.io, .{
+ .argv = &.{ "pass", "edit", target },
+ .stdin = .inherit,
+ .stdout = .inherit,
+ .stderr = .inherit,
+ });
+
+ const term = try child.wait(init.io);
+ // `pass edit` exits non-zero with "Password unchanged." when the editor
+ // saved no changes. That is not a failure here.
+ if (term != .exited) return error.FailedExit;
+}
+
+test "parseFzfOutput query only" {
+ const parts = parseFzfOutput("foo\n") orelse return error.TestUnexpectedResult;
+ try std.testing.expectEqualStrings("foo", parts.query);
+ try std.testing.expectEqualStrings("", parts.key);
+ try std.testing.expectEqualStrings("", parts.selected);
+}
+
+test "parseFzfOutput query key selection" {
+ const parts = parseFzfOutput("foo\nctrl-e\nbar\n") orelse return error.TestUnexpectedResult;
+ try std.testing.expectEqualStrings("foo", parts.query);
+ try std.testing.expectEqualStrings("ctrl-e", parts.key);
+ try std.testing.expectEqualStrings("bar", parts.selected);
+}
+
+test "parseFzfOutput enter with selection" {
+ const parts = parseFzfOutput("foo\n\nbar\n") orelse return error.TestUnexpectedResult;
+ try std.testing.expectEqualStrings("foo", parts.query);
+ try std.testing.expectEqualStrings("", parts.key);
+ try std.testing.expectEqualStrings("bar", parts.selected);
+}
+
+test "parseFzfOutput empty query ctrl-e" {
+ const parts = parseFzfOutput("\nctrl-e\n\n") orelse return error.TestUnexpectedResult;
+ try std.testing.expectEqualStrings("", parts.query);
+ try std.testing.expectEqualStrings("ctrl-e", parts.key);
+ try std.testing.expectEqualStrings("", parts.selected);
+}
- return try alloc.dupe(u8, trimmed);
+test "parseFzfOutput empty" {
+ try std.testing.expect(parseFzfOutput("\n\n\n") == null);
+ try std.testing.expect(parseFzfOutput("") == null);
}
fn run(init: std.process.Init, alloc: std.mem.Allocator, args: []const []const u8) ![]u8 {
@@ -215,7 +306,7 @@ fn store(init: std.process.Init, alloc: std.mem.Allocator, name: []const u8) ![]
errdefer alloc.free(password);
inner(init, alloc, name, password) catch |err| {
- std.debug.print("Failed to store password: {}\n", .{err});
+ std.log.err("failed to store password: {}", .{err});
return err;
};
return password;