Commit Diff


commit - 635534fb96c0fa7f53eb33f82d3f846d30290e0f
commit + e120dd4e0f76d869ab67370525b2829704770c06
blob - 1c20445717b4d84068094a675558adf2b7363ada
blob + e80509d0f14da56cf8d125ae2a3e1f49391ebc77
--- src/command_line.zig
+++ src/command_line.zig
@@ -27,6 +27,7 @@ pub const Arguments = struct {
     file_type: ?[]const u8 = null,
     file_action: ?[]const u8 = null,
     depth: ?usize = null,
+    choose_file: ?[]const u8 = null,
 
     _command_line: CommandLineArguments,
 
@@ -225,6 +226,10 @@ fn resolveArguments(command_line: CommandLineArguments
     if (parsed_arguments.depth == null) {
         parsed_arguments.depth = try scanTailDepth(command_line.tail);
     }
+    parsed_arguments.choose_file = parseChooseFileFlag(&command_line);
+    if (parsed_arguments.choose_file == null) {
+        parsed_arguments.choose_file = scanTailChooseFile(command_line.tail);
+    }
 
     if (command_line.tail.len == 0) {
         return parsed_arguments;
@@ -263,6 +268,46 @@ fn parseDepthFlag(command_line: *const CommandLineArgu
     return null;
 }
 
+fn parseChooseFileFlag(command_line: *const CommandLineArguments) ?[]const u8 {
+    if (command_line.get("choose-file")) |path| {
+        if (path.len > 0) {
+            return path;
+        }
+        return null;
+    }
+    return null;
+}
+
+fn scanTailChooseFile(tail: []const [:0]const u8) ?[]const u8 {
+    // Chooser output for editors: `magdalena --choose-file out look-file` writes
+    // the picked path instead of opening an editor, so embedded terminals can
+    // read the result back after the picker exits.
+    const prefix = "--choose-file=";
+    assert(prefix.len > 0);
+    for (tail, 0..) |argument, index| {
+        assert(index < tail.len);
+        if (std.mem.startsWith(u8, argument, prefix)) {
+            assert(argument.len >= prefix.len);
+            const path = argument[prefix.len..];
+            if (path.len > 0) {
+                return path;
+            }
+            return null;
+        }
+        if (std.mem.eql(u8, argument, "--choose-file")) {
+            if (index + 1 < tail.len) {
+                assert(index + 1 < tail.len);
+                const path: []const u8 = tail[index + 1];
+                if (path.len > 0) {
+                    return path;
+                }
+            }
+            return null;
+        }
+    }
+    return null;
+}
+
 fn scanTailDepth(tail: []const [:0]const u8) !?usize {
     const prefix = "--depth=";
     assert(prefix.len > 0);
@@ -302,7 +347,10 @@ fn fillActionArguments(parsed_arguments: *Arguments, t
             parsed_arguments.query = argument;
         },
         .grep => {
-            parsed_arguments.query = argument;
+            // A leading dash means a flag such as --choose-file, not a query.
+            if (argument.len == 0 or argument[0] != '-') {
+                parsed_arguments.query = argument;
+            }
         },
         .log_directory => {
             parsed_arguments.log_path = argument;
@@ -338,6 +386,7 @@ pub fn printUsage() !void {
         \\  magdalena grep [query]
         \\  magdalena git-status
         \\  magdalena select
+        \\  --choose-file <path>   write selection to file, do not open
         \\
         \\Options:
         \\  -c, --clean             Perform cleanup
@@ -534,6 +583,38 @@ test "resolveArguments: grep carries optional query" {
     try testing.expect(bare.query == null);
 }
 
+test "resolveArguments: choose-file flag before subcommand" {
+    var arguments = try testArguments(&[_][:0]const u8{
+        "magdalena",
+        "--choose-file",
+        "/tmp/choice",
+        "look-file",
+    });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.look_file, arguments.action);
+    try testing.expectEqualStrings("/tmp/choice", arguments.choose_file.?);
+}
+
+test "resolveArguments: choose-file flag after subcommand" {
+    var arguments = try testArguments(&[_][:0]const u8{
+        "magdalena",
+        "grep",
+        "needle",
+        "--choose-file=/tmp/choice",
+    });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.grep, arguments.action);
+    try testing.expectEqualStrings("needle", arguments.query.?);
+    try testing.expectEqualStrings("/tmp/choice", arguments.choose_file.?);
+}
+
+test "resolveArguments: no choose-file leaves it null" {
+    var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "goto-file" });
+    defer arguments.deinit();
+    try testing.expectEqual(Action.goto_file, arguments.action);
+    try testing.expect(arguments.choose_file == null);
+}
+
 test "resolveArguments: log-file carries path, type and action" {
     var arguments = try testArguments(&[_][:0]const u8{
         "magdalena",
blob - 06499f56581312fb457a33fa8ca60ac7d41dbd40
blob + d339912909671aad2321500badb9d3f6f207508d
--- src/main.zig
+++ src/main.zig
@@ -75,32 +75,38 @@ fn runAction(
             try printRecentFiles(&writer, history);
         },
         .favorites => {
-            try navigate.favorites(allocator, history, app_config);
+            try navigate.favorites(allocator, history, app_config, arguments.choose_file);
         },
+        .git_status => {
+            try navigate.gitStatus(allocator, history, app_config, arguments.choose_file);
+        },
+        .select => {
+            try navigate.selectFromStdin(allocator, app_config, arguments.choose_file);
+        },
         .search => {
             try printSearchResults(&writer, history, arguments.query);
         },
         .goto_directory => {
-            try navigate.recentDirectory(allocator, history, app_config);
+            try navigate.recentDirectory(allocator, history, app_config, arguments.choose_file);
         },
         .goto_file => {
-            try navigate.recentFile(allocator, history, app_config);
+            try navigate.recentFile(allocator, history, app_config, arguments.choose_file);
         },
         .look_file => {
-            try navigate.lookFile(allocator, history, app_config, arguments.depth);
+            try navigate.lookFile(allocator, history, app_config, arguments.depth, arguments.choose_file);
         },
         .look_directory => {
-            try navigate.lookDirectory(allocator, history, app_config, arguments.depth);
+            try navigate.lookDirectory(
+                allocator,
+                history,
+                app_config,
+                arguments.depth,
+                arguments.choose_file,
+            );
         },
         .grep => {
-            try navigate.grep(allocator, history, app_config, arguments.query);
+            try navigate.grep(allocator, history, app_config, arguments.query, arguments.choose_file);
         },
-        .git_status => {
-            try navigate.gitStatus(allocator, history, app_config);
-        },
-        .select => {
-            try navigate.selectFromStdin(allocator, app_config);
-        },
         .cleanup => {
             try history.cleanup();
         },
blob - 26f29aa304a5527b346d063d4f6c54c4b3add0ec
blob + 01ca487d12d989efbfa1dc77f50065c18f0d9e8f
--- src/navigate.zig
+++ src/navigate.zig
@@ -28,10 +28,26 @@ fn gotoFile(
     file_path: []const u8,
     line: ?[]const u8,
     app_config: *const config.Config,
+    choose_file: ?[]const u8,
 ) !void {
     if (file_path.len == 0) {
         return error.EmptyPath;
     }
+    if (choose_file) |output| {
+        // Embedded pickers read the choice back from a file instead of
+        // replacing the process with an editor, so skip logging and tty
+        // setup here. The embedding editor owns the open event.
+        if (line) |line_text| {
+            var choice_buf: [std.fs.max_path_bytes + 32]u8 = undefined;
+            const choice = try std.fmt.bufPrint(
+                &choice_buf,
+                "{s}:{s}",
+                .{ file_path, line_text },
+            );
+            return writeChoice(output, choice);
+        }
+        return writeChoice(output, file_path);
+    }
     const dot_index = std.mem.lastIndexOfScalar(u8, file_path, '.');
     const extension = if (dot_index) |index| file_path[index + 1 ..] else "";
     const opener = matchOpener(app_config, extension);
@@ -43,21 +59,8 @@ fn gotoFile(
         };
     }
 
-    if (std.Io.Dir.openFileAbsolute(input_output.runtime(), "/dev/tty", .{
-        .mode = .read_write,
-        .allow_directory = false,
-        .path_only = false,
-    })) |tty| {
-        if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) {
-            input_output.warn("failed to redirect stdin to tty\n", .{});
-        }
-        if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) {
-            input_output.warn("failed to redirect stdout to tty\n", .{});
-        }
-        tty.close(input_output.runtime());
-    } else |err| {
-        input_output.warn("failed to open /dev/tty: {}\n", .{err});
-    }
+    redirectStdioToTty();
+    restoreResizeSignals();
 
     const parent_directory = std.fs.path.dirname(file_path) orelse ".";
     assert(parent_directory.len > 0);
@@ -82,6 +85,53 @@ fn gotoFile(
     return std.process.replace(input_output.runtime(), .{ .argv = &.{ editor, base_name } });
 }
 
+// The editor replaces this process, so it inherits our stdio. Point all three at the
+// controlling terminal; a half-redirected stderr leaves the TUI without a terminal.
+fn redirectStdioToTty() void {
+    const tty = std.Io.Dir.openFileAbsolute(input_output.runtime(), "/dev/tty", .{
+        .mode = .read_write,
+        .allow_directory = false,
+        .path_only = false,
+    }) catch |err| {
+        input_output.warn("failed to open /dev/tty: {}\n", .{err});
+        return;
+    };
+    const fd = tty.handle;
+    if (std.c.dup2(fd, std.posix.STDIN_FILENO) < 0) {
+        input_output.warn("failed to redirect stdin to tty\n", .{});
+    }
+    if (std.c.dup2(fd, std.posix.STDOUT_FILENO) < 0) {
+        input_output.warn("failed to redirect stdout to tty\n", .{});
+    }
+    if (std.c.dup2(fd, std.posix.STDERR_FILENO) < 0) {
+        input_output.warn("failed to redirect stderr to tty\n", .{});
+    }
+    if (shouldCloseTtyFd(fd)) {
+        tty.close(input_output.runtime());
+    }
+}
+
+// Opening /dev/tty can reuse fd 0, 1, or 2 when one is closed. Closing the source fd
+// after dup2 would then close the std stream just set up.
+fn shouldCloseTtyFd(fd: std.posix.fd_t) bool {
+    return fd > std.posix.STDERR_FILENO;
+}
+
+// Caught handlers reset on exec but ignored signals and the signal mask survive it.
+// The picker owns SIGWINCH while selecting, so restore default handling here. Without
+// this the editor never learns about tmux pane resizes and keeps its old size.
+fn restoreResizeSignals() void {
+    var unblock = std.posix.sigemptyset();
+    std.posix.sigaddset(&unblock, std.posix.SIG.WINCH);
+    std.posix.sigprocmask(std.posix.SIG.UNBLOCK, &unblock, null);
+    const default_action = std.posix.Sigaction{
+        .handler = .{ .handler = std.posix.SIG.DFL },
+        .mask = std.posix.sigemptyset(),
+        .flags = 0,
+    };
+    std.posix.sigaction(std.posix.SIG.WINCH, &default_action, null);
+}
+
 const OpenerMatch = struct {
     action: []const u8,
     command: ?[]const u8,
@@ -135,10 +185,39 @@ fn printPath(path: []const u8) !void {
     try out.end();
 }
 
+fn writeChoice(choose_file: []const u8, content: []const u8) !void {
+    assert(choose_file.len > 0);
+    assert(content.len > 0);
+    var file = try std.Io.Dir.cwd().createFile(input_output.runtime(), choose_file, .{
+        .read = false,
+        .truncate = true,
+        .exclusive = false,
+        .lock = .none,
+        .lock_nonblocking = false,
+        .permissions = .fromMode(0o600),
+        .resolve_beneath = false,
+    });
+    defer file.close(input_output.runtime());
+    var buffer: [4096]u8 = undefined;
+    var writer = file.writer(input_output.runtime(), &buffer);
+    try writer.interface.writeAll(content);
+    try writer.interface.writeAll("\n");
+    try writer.interface.flush();
+}
+
+fn emitChoice(choose_file: ?[]const u8, content: []const u8) !void {
+    assert(content.len > 0);
+    if (choose_file) |output| {
+        return writeChoice(output, content);
+    }
+    return printPath(content);
+}
+
 pub fn recentDirectory(
     allocator: std.mem.Allocator,
     history: *database.Database,
     app_config: *const config.Config,
+    choose_file: ?[]const u8,
 ) !void {
     const managed_directories = try history.recentDirectories();
     defer managed_directories.deinit();
@@ -175,13 +254,14 @@ pub fn recentDirectory(
         return error.UserAbort;
     };
 
-    try printPath(selected);
+    try emitChoice(choose_file, selected);
 }
 
 pub fn favorites(
     allocator: std.mem.Allocator,
     history: *database.Database,
     app_config: *const config.Config,
+    choose_file: ?[]const u8,
 ) !void {
     if (app_config.favorites.len == 0) {
         input_output.warn("No favorites found in config.\n", .{});
@@ -209,11 +289,13 @@ pub fn favorites(
     const is_dir = isDirectory(selected);
 
     if (is_dir) {
-        try history.logDirectory(selected);
-        try printPath(selected);
+        if (choose_file == null) {
+            try history.logDirectory(selected);
+        }
+        try emitChoice(choose_file, selected);
         return;
     }
-    try gotoFile(history, selected, null, app_config);
+    try gotoFile(history, selected, null, app_config, choose_file);
 }
 
 fn isDirectory(path: []const u8) bool {
@@ -274,6 +356,7 @@ pub fn recentFile(
     allocator: std.mem.Allocator,
     history: *database.Database,
     app_config: *const config.Config,
+    choose_file: ?[]const u8,
 ) !void {
     const managed_files = try history.recentFiles();
     defer managed_files.deinit();
@@ -307,7 +390,7 @@ pub fn recentFile(
         .execute = false,
     });
 
-    try gotoFile(history, selected, null, app_config);
+    try gotoFile(history, selected, null, app_config, choose_file);
 }
 
 pub fn grep(
@@ -315,6 +398,7 @@ pub fn grep(
     history: *database.Database,
     app_config: *const config.Config,
     initial_query: ?[]const u8,
+    choose_file: ?[]const u8,
 ) !void {
     assert(history.base_path.len > 0);
     const theme = pickerTheme(app_config);
@@ -328,7 +412,7 @@ pub fn grep(
     assert(selected.len > 0);
 
     const target = splitGrepSelection(selected);
-    try gotoFile(history, target.file, target.line, app_config);
+    try gotoFile(history, target.file, target.line, app_config, choose_file);
 }
 
 const GrepSelection = struct {
@@ -425,6 +509,7 @@ pub fn gitStatus(
     allocator: std.mem.Allocator,
     history: *database.Database,
     app_config: *const config.Config,
+    choose_file: ?[]const u8,
 ) !void {
     const run_result = std.process.run(allocator, input_output.runtime(), .{
         .argv = &.{ "git", "status", "--porcelain=v1", "--untracked-files=normal" },
@@ -473,7 +558,7 @@ pub fn gitStatus(
         .write = false,
         .execute = false,
     });
-    try gotoFile(history, file, null, app_config);
+    try gotoFile(history, file, null, app_config, choose_file);
 }
 
 pub fn parseSelectLines(
@@ -504,6 +589,7 @@ pub fn freeSelectLines(allocator: std.mem.Allocator, i
 pub fn selectFromStdin(
     allocator: std.mem.Allocator,
     app_config: *const config.Config,
+    choose_file: ?[]const u8,
 ) !void {
     var stdin_buffer: [stdin_buffer_bytes]u8 = undefined;
     var reader = std.Io.File.stdin().readerStreaming(input_output.runtime(), &stdin_buffer);
@@ -523,7 +609,7 @@ pub fn selectFromStdin(
     const selected = try picker.pickFromList(allocator, items, theme);
     defer allocator.free(selected);
     assert(selected.len > 0);
-    try printPath(selected);
+    try emitChoice(choose_file, selected);
 }
 
 pub fn lookFile(
@@ -531,8 +617,9 @@ pub fn lookFile(
     history: *database.Database,
     app_config: *const config.Config,
     depth: ?usize,
+    choose_file: ?[]const u8,
 ) !void {
-    try runFzfWithWalker(allocator, history, .file, app_config, depth);
+    try runFzfWithWalker(allocator, history, .file, app_config, depth, choose_file);
 }
 
 pub fn lookDirectory(
@@ -540,8 +627,9 @@ pub fn lookDirectory(
     history: *database.Database,
     app_config: *const config.Config,
     depth: ?usize,
+    choose_file: ?[]const u8,
 ) !void {
-    try runFzfWithWalker(allocator, history, .directory, app_config, depth);
+    try runFzfWithWalker(allocator, history, .directory, app_config, depth, choose_file);
 }
 
 const WalkItem = struct {
@@ -993,6 +1081,7 @@ fn runFzfWithWalker(
     kind: std.Io.File.Kind,
     app_config: *const config.Config,
     depth: ?usize,
+    choose_file: ?[]const u8,
 ) !void {
     const searching_directories = kind == .directory;
     const max_depth = depth orelse app_config.max_depth;
@@ -1033,7 +1122,14 @@ fn runFzfWithWalker(
     walk_cache.client = null;
     walk_cache.key = null;
 
-    try sortPickAndOpen(allocator, history, app_config, searching_directories, &entries);
+    try sortPickAndOpen(
+        allocator,
+        history,
+        app_config,
+        searching_directories,
+        &entries,
+        choose_file,
+    );
 }
 
 fn compareEntryMtimeDesc(_: void, left: Entry, right: Entry) bool {
@@ -1051,6 +1147,7 @@ fn sortPickAndOpen(
     app_config: *const config.Config,
     searching_directories: bool,
     entries: *std.ArrayListUnmanaged(Entry),
+    choose_file: ?[]const u8,
 ) !void {
     std.mem.sortUnstable(Entry, entries.items, {}, compareEntryMtimeDesc);
 
@@ -1085,10 +1182,10 @@ fn sortPickAndOpen(
         return error.UserAbort;
     };
     if (searching_directories) {
-        try printPath(clean_path);
+        try emitChoice(choose_file, clean_path);
         return;
     }
-    try gotoFile(history, clean_path, null, app_config);
+    try gotoFile(history, clean_path, null, app_config, choose_file);
 }
 
 const testing = std.testing;
@@ -1143,9 +1240,38 @@ test "matchOpener: openers with empty command or actio
     try testing.expectEqualStrings("nvim", matchOpener(&app_config, "zig").command.?);
 }
 
+test "shouldCloseTtyFd: std fds stay open, fresh fds close" {
+    try testing.expect(!shouldCloseTtyFd(std.posix.STDIN_FILENO));
+    try testing.expect(!shouldCloseTtyFd(std.posix.STDOUT_FILENO));
+    try testing.expect(!shouldCloseTtyFd(std.posix.STDERR_FILENO));
+    try testing.expect(shouldCloseTtyFd(3));
+    try testing.expect(shouldCloseTtyFd(10));
+}
+
+test "restoreResizeSignals: unblocks and defaults SIGWINCH" {
+    var block = std.posix.sigemptyset();
+    std.posix.sigaddset(&block, std.posix.SIG.WINCH);
+    std.posix.sigprocmask(std.posix.SIG.BLOCK, &block, null);
+    const ignore_action = std.posix.Sigaction{
+        .handler = .{ .handler = std.posix.SIG.IGN },
+        .mask = std.posix.sigemptyset(),
+        .flags = 0,
+    };
+    std.posix.sigaction(std.posix.SIG.WINCH, &ignore_action, null);
+
+    restoreResizeSignals();
+
+    var old_action: std.posix.Sigaction = undefined;
+    std.posix.sigaction(std.posix.SIG.WINCH, null, &old_action);
+    try testing.expectEqual(std.posix.SIG.DFL, old_action.handler.handler);
+    var old_mask = std.posix.sigemptyset();
+    std.posix.sigprocmask(std.posix.SIG.SETMASK, null, &old_mask);
+    try testing.expect(!std.posix.sigismember(&old_mask, std.posix.SIG.WINCH));
+}
+
 test "gotoFile: empty path is an error, not a crash" {
     const app_config = config.Config{};
-    const result = gotoFile(null, "", null, &app_config);
+    const result = gotoFile(null, "", null, &app_config, null);
     try testing.expectError(error.EmptyPath, result);
 }
 
@@ -1329,7 +1455,7 @@ test "runFzfWithWalker: rejects depths above the suppo
     const too_deep = max_supported_depth + 1;
     try testing.expectError(
         error.DepthTooLarge,
-        runFzfWithWalker(testing.allocator, &test_history, .file, &app_config, too_deep),
+        runFzfWithWalker(testing.allocator, &test_history, .file, &app_config, too_deep, null),
     );
 }
 
@@ -1381,6 +1507,64 @@ test "parseSelectLines: drops blanks and trims carriag
     try testing.expectEqualStrings("src/lib.zig", items[2]);
 }
 
+test "gotoFile choose mode writes path without opening" {
+    var env_map = std.process.Environ.Map.init(testing.allocator);
+    defer env_map.deinit();
+    input_output.init(testing.io, &env_map);
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_io = testing.io;
+    const cwd = try std.process.currentPathAlloc(test_io, testing.allocator);
+    defer testing.allocator.free(cwd);
+    const choice_path = try std.fs.path.join(testing.allocator, &.{
+        cwd,
+        ".zig-cache",
+        "tmp",
+        temp_directory.sub_path[0..],
+        "choice",
+    });
+    defer testing.allocator.free(choice_path);
+    const app_config = config.Config{};
+    try gotoFile(null, "src/main.zig", null, &app_config, choice_path);
+    const stored = try temp_directory.dir.readFileAlloc(
+        test_io,
+        "choice",
+        testing.allocator,
+        .limited(4096),
+    );
+    defer testing.allocator.free(stored);
+    try testing.expectEqualStrings("src/main.zig\n", stored);
+}
+
+test "gotoFile choose mode writes file and line" {
+    var env_map = std.process.Environ.Map.init(testing.allocator);
+    defer env_map.deinit();
+    input_output.init(testing.io, &env_map);
+    var temp_directory = testing.tmpDir(.{ .iterate = true });
+    defer temp_directory.cleanup();
+    const test_io = testing.io;
+    const cwd = try std.process.currentPathAlloc(test_io, testing.allocator);
+    defer testing.allocator.free(cwd);
+    const choice_path = try std.fs.path.join(testing.allocator, &.{
+        cwd,
+        ".zig-cache",
+        "tmp",
+        temp_directory.sub_path[0..],
+        "choice-line",
+    });
+    defer testing.allocator.free(choice_path);
+    const app_config = config.Config{};
+    try gotoFile(null, "src/main.zig", "12", &app_config, choice_path);
+    const stored = try temp_directory.dir.readFileAlloc(
+        test_io,
+        "choice-line",
+        testing.allocator,
+        .limited(4096),
+    );
+    defer testing.allocator.free(stored);
+    try testing.expectEqualStrings("src/main.zig:12\n", stored);
+}
+
 test "parseSelectLines: empty input yields no candidates" {
     const items = try parseSelectLines(testing.allocator, "\n  \n");
     defer freeSelectLines(testing.allocator, items);