commit - 635534fb96c0fa7f53eb33f82d3f846d30290e0f
commit + e120dd4e0f76d869ab67370525b2829704770c06
blob - 1c20445717b4d84068094a675558adf2b7363ada
blob + e80509d0f14da56cf8d125ae2a3e1f49391ebc77
--- src/command_line.zig
+++ src/command_line.zig
file_type: ?[]const u8 = null,
file_action: ?[]const u8 = null,
depth: ?usize = null,
+ choose_file: ?[]const u8 = null,
_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;
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);
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;
\\ magdalena grep [query]
\\ magdalena git-status
\\ magdalena select
+ \\ --choose-file <path> write selection to file, do not open
\\
\\Options:
\\ -c, --clean Perform cleanup
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
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
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);
};
}
- 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);
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,
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();
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", .{});
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 {
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();
.execute = false,
});
- try gotoFile(history, selected, null, app_config);
+ try gotoFile(history, selected, null, app_config, choose_file);
}
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);
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 {
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" },
.write = false,
.execute = false,
});
- try gotoFile(history, file, null, app_config);
+ try gotoFile(history, file, null, app_config, choose_file);
}
pub fn parseSelectLines(
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);
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(
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(
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 {
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;
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 {
app_config: *const config.Config,
searching_directories: bool,
entries: *std.ArrayListUnmanaged(Entry),
+ choose_file: ?[]const u8,
) !void {
std.mem.sortUnstable(Entry, entries.items, {}, compareEntryMtimeDesc);
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;
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);
}
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),
);
}
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);