commit e120dd4e0f76d869ab67370525b2829704770c06 from: mtmn date: Tue Sep 15 22:23:24 2026 UTC term handoff before exec 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 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);