commit 635534fb96c0fa7f53eb33f82d3f846d30290e0f from: mtmn date: Tue Sep 15 22:10:51 2026 UTC add status and select subcommands commit - 2041a691fc814e962e906653821c729b17d3284f commit + 635534fb96c0fa7f53eb33f82d3f846d30290e0f blob - c61b4bb40e8d5eab8df4f7620c62914300444eed blob + ae284dfd353c14f6b2259cce4de3298ef58bdfcf --- README.md +++ README.md @@ -37,7 +37,9 @@ magdalena log-file [type] [action] magdalena search magdalena look-file [--depth ] magdalena look-directory [--depth ] -magdalena grep +magdalena grep [query] +magdalena git-status +magdalena select ``` ### Options @@ -59,4 +61,13 @@ magdalena grep # Explore directory with depth 3 ./zig-out/bin/magdalena look-directory --depth 3 + +# Pick a changed file from git status +./zig-out/bin/magdalena git-status + +# Grep with a starting query prefilled +./zig-out/bin/magdalena grep "todo" + +# Fuzzy pick from any piped list +git ls-files | ./zig-out/bin/magdalena select ``` blob - 97ad8d0c25bcf0700acd252aeb7025c2bd3f2b59 blob + 1c20445717b4d84068094a675558adf2b7363ada --- src/command_line.zig +++ src/command_line.zig @@ -6,7 +6,9 @@ pub const Action = enum { recent_directories, recent_files, favorites, + git_status, search, + select, goto_directory, goto_file, look_file, @@ -38,6 +40,7 @@ const subcommand_table: []const struct { action: Action, } = &.{ .{ .name = "favorites", .action = .favorites }, + .{ .name = "git-status", .action = .git_status }, .{ .name = "goto-directory", .action = .goto_directory }, .{ .name = "goto-file", .action = .goto_file }, .{ .name = "grep", .action = .grep }, @@ -48,6 +51,7 @@ const subcommand_table: []const struct { .{ .name = "recent-directories", .action = .recent_directories }, .{ .name = "recent-files", .action = .recent_files }, .{ .name = "search", .action = .search }, + .{ .name = "select", .action = .select }, }; comptime { @@ -297,6 +301,9 @@ fn fillActionArguments(parsed_arguments: *Arguments, t .search => { parsed_arguments.query = argument; }, + .grep => { + parsed_arguments.query = argument; + }, .log_directory => { parsed_arguments.log_path = argument; }, @@ -328,7 +335,9 @@ pub fn printUsage() !void { \\ magdalena search \\ magdalena look-file [--depth ] \\ magdalena look-directory [--depth ] - \\ magdalena grep + \\ magdalena grep [query] + \\ magdalena git-status + \\ magdalena select \\ \\Options: \\ -c, --clean Perform cleanup @@ -496,6 +505,8 @@ test "resolveArguments: simple subcommands" { .{ .command = "goto-directory", .action = .goto_directory }, .{ .command = "goto-file", .action = .goto_file }, .{ .command = "grep", .action = .grep }, + .{ .command = "git-status", .action = .git_status }, + .{ .command = "select", .action = .select }, }; for (cases) |case| { var arguments = try testArguments(&[_][:0]const u8{ "magdalena", case.command }); @@ -511,6 +522,18 @@ test "resolveArguments: search carries query" { try testing.expectEqualStrings("needle", arguments.query.?); } +test "resolveArguments: grep carries optional query" { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "grep", "needle" }); + defer arguments.deinit(); + try testing.expectEqual(Action.grep, arguments.action); + try testing.expectEqualStrings("needle", arguments.query.?); + + var bare = try testArguments(&[_][:0]const u8{ "magdalena", "grep" }); + defer bare.deinit(); + try testing.expectEqual(Action.grep, bare.action); + try testing.expect(bare.query == null); +} + test "resolveArguments: log-file carries path, type and action" { var arguments = try testArguments(&[_][:0]const u8{ "magdalena", blob - 35e914d18d1d839d5c5f9357afe5a232f55bc653 blob + 06499f56581312fb457a33fa8ca60ac7d41dbd40 --- src/main.zig +++ src/main.zig @@ -93,8 +93,14 @@ fn runAction( try navigate.lookDirectory(allocator, history, app_config, arguments.depth); }, .grep => { - try navigate.grep(allocator, history, app_config); + try navigate.grep(allocator, history, app_config, arguments.query); }, + .git_status => { + try navigate.gitStatus(allocator, history, app_config); + }, + .select => { + try navigate.selectFromStdin(allocator, app_config); + }, .cleanup => { try history.cleanup(); }, blob - 901d36a23dcc38693c8f459f173eda9db9c54ff5 blob + 26f29aa304a5527b346d063d4f6c54c4b3add0ec --- src/navigate.zig +++ src/navigate.zig @@ -7,9 +7,15 @@ const input_output = @import("input_output.zig"); const picker = @import("picker.zig"); const max_supported_depth: usize = 128; +const max_git_status_bytes: usize = 4 * 1024 * 1024; +const max_select_stdin_bytes: usize = 8 * 1024 * 1024; +const stdin_buffer_bytes: usize = 8192; comptime { assert(max_supported_depth > 0); + assert(max_git_status_bytes > 0); + assert(max_select_stdin_bytes > 0); + assert(stdin_buffer_bytes > 0); } const Entry = struct { @@ -308,10 +314,16 @@ pub fn grep( allocator: std.mem.Allocator, history: *database.Database, app_config: *const config.Config, + initial_query: ?[]const u8, ) !void { assert(history.base_path.len > 0); const theme = pickerTheme(app_config); - const selected = try picker.pickGrepLine(allocator, app_config.ignored_patterns, theme); + const selected = try picker.pickGrepLine( + allocator, + app_config.ignored_patterns, + theme, + initial_query, + ); defer allocator.free(selected); assert(selected.len > 0); @@ -335,6 +347,185 @@ fn splitGrepSelection(selected: []const u8) GrepSelect unreachable; } +pub const GitStatusEntry = struct { + status: [2]u8, + path: []const u8, + display: []const u8, +}; + +// Porcelain v1 lines look like `XY PATH` where columns 1 and 2 are the staged +// and unstaged status. Renames carry `OLD -> NEW` so the picker must open the +// new path. Git quotes paths with special characters, hence the unquoting. +pub fn parseGitStatusPorcelain( + allocator: std.mem.Allocator, + output: []const u8, +) ![]GitStatusEntry { + var entries = std.ArrayListUnmanaged(GitStatusEntry).empty; + errdefer { + for (entries.items) |entry| { + allocator.free(entry.path); + allocator.free(entry.display); + } + entries.deinit(allocator); + } + var split = std.mem.splitScalar(u8, output, '\n'); + while (split.next()) |line| { + const clean = std.mem.trimEnd(u8, line, " \t\r"); + if (clean.len < 4) { + continue; + } + assert(clean.len >= 4); + if (clean[2] != ' ') { + continue; + } + const status = [2]u8{ clean[0], clean[1] }; + var path = unquotePath(clean[3..]); + if (std.mem.indexOf(u8, path, " -> ")) |arrow| { + path = unquotePath(path[arrow + 4 ..]); + } + if (path.len == 0) { + continue; + } + const owned_path = try allocator.dupe(u8, path); + errdefer allocator.free(owned_path); + const display = try std.fmt.allocPrint( + allocator, + "{c}{c} {s}", + .{ status[0], status[1], owned_path }, + ); + try entries.append(allocator, .{ + .status = status, + .path = owned_path, + .display = display, + }); + } + return entries.toOwnedSlice(allocator); +} + +fn unquotePath(path: []const u8) []const u8 { + const trimmed = std.mem.trim(u8, path, " \t"); + if (trimmed.len >= 2 and trimmed[0] == '"' and trimmed[trimmed.len - 1] == '"') { + return trimmed[1 .. trimmed.len - 1]; + } + return trimmed; +} + +pub fn freeGitStatusEntries( + allocator: std.mem.Allocator, + entries: []GitStatusEntry, +) void { + for (entries) |entry| { + allocator.free(entry.path); + allocator.free(entry.display); + } + allocator.free(entries); +} + +pub fn gitStatus( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, +) !void { + const run_result = std.process.run(allocator, input_output.runtime(), .{ + .argv = &.{ "git", "status", "--porcelain=v1", "--untracked-files=normal" }, + .stdout_limit = .limited(max_git_status_bytes), + .stderr_limit = .limited(65536), + }) catch |err| { + input_output.warn("git status could not start: {}\n", .{err}); + return error.UserAbort; + }; + defer allocator.free(run_result.stdout); + defer allocator.free(run_result.stderr); + if (run_result.term != .exited) { + input_output.warn("git status did not exit normally.\n", .{}); + return error.UserAbort; + } + if (run_result.term.exited != 0) { + input_output.warn("git status failed with exit code {d}.\n", .{run_result.term.exited}); + return error.UserAbort; + } + const entries = try parseGitStatusPorcelain(allocator, run_result.stdout); + defer freeGitStatusEntries(allocator, entries); + if (entries.len == 0) { + input_output.warn("No changed files found.\n", .{}); + return; + } + var items = std.ArrayListUnmanaged([]const u8).empty; + defer items.deinit(allocator); + for (entries) |entry| { + try items.append(allocator, entry.display); + } + const theme = pickerTheme(app_config); + const selected = try picker.pickFromList(allocator, items.items, theme); + defer allocator.free(selected); + assert(selected.len > 0); + var target: ?[]const u8 = null; + for (entries) |entry| { + if (std.mem.eql(u8, entry.display, selected)) { + target = entry.path; + break; + } + } + const file = target orelse return error.UserAbort; + try std.Io.Dir.accessAbsolute(input_output.runtime(), file, .{ + .follow_symlinks = true, + .read = false, + .write = false, + .execute = false, + }); + try gotoFile(history, file, null, app_config); +} + +pub fn parseSelectLines( + allocator: std.mem.Allocator, + input: []const u8, +) ![][]const u8 { + var items = std.ArrayListUnmanaged([]const u8).empty; + errdefer { + for (items.items) |item| allocator.free(item); + items.deinit(allocator); + } + var split = std.mem.splitScalar(u8, input, '\n'); + while (split.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) { + continue; + } + try items.append(allocator, try allocator.dupe(u8, trimmed)); + } + return items.toOwnedSlice(allocator); +} + +pub fn freeSelectLines(allocator: std.mem.Allocator, items: [][]const u8) void { + for (items) |item| allocator.free(item); + allocator.free(items); +} + +pub fn selectFromStdin( + allocator: std.mem.Allocator, + app_config: *const config.Config, +) !void { + var stdin_buffer: [stdin_buffer_bytes]u8 = undefined; + var reader = std.Io.File.stdin().readerStreaming(input_output.runtime(), &stdin_buffer); + const stdin_limit: std.Io.Limit = .limited(max_select_stdin_bytes); + const data = reader.interface.allocRemaining(allocator, stdin_limit) catch |err| { + input_output.warn("failed to read stdin: {}\n", .{err}); + return error.UserAbort; + }; + defer allocator.free(data); + const items = try parseSelectLines(allocator, data); + defer freeSelectLines(allocator, items); + if (items.len == 0) { + input_output.warn("No candidates on stdin.\n", .{}); + return; + } + const theme = pickerTheme(app_config); + const selected = try picker.pickFromList(allocator, items, theme); + defer allocator.free(selected); + assert(selected.len > 0); + try printPath(selected); +} + pub fn lookFile( allocator: std.mem.Allocator, history: *database.Database, @@ -1142,6 +1333,60 @@ test "runFzfWithWalker: rejects depths above the suppo ); } +test "parseGitStatusPorcelain: staged, unstaged and untracked entries" { + const output = "M src/main.zig\n A src/new.zig\nMM src/both.zig\n?? notes/todo.txt\n"; + const entries = try parseGitStatusPorcelain(testing.allocator, output); + defer freeGitStatusEntries(testing.allocator, entries); + try testing.expectEqual(@as(usize, 4), entries.len); + try testing.expectEqualStrings("src/main.zig", entries[0].path); + try testing.expectEqual([2]u8{ 'M', ' ' }, entries[0].status); + try testing.expectEqualStrings("src/new.zig", entries[1].path); + try testing.expectEqual([2]u8{ ' ', 'A' }, entries[1].status); + try testing.expectEqualStrings("src/both.zig", entries[2].path); + try testing.expectEqualStrings("notes/todo.txt", entries[3].path); + try testing.expectEqual([2]u8{ '?', '?' }, entries[3].status); + try testing.expectEqualStrings("M src/main.zig", entries[0].display); +} + +test "parseGitStatusPorcelain: rename resolves to new path" { + const output = "R src/old.zig -> src/new.zig\n"; + const entries = try parseGitStatusPorcelain(testing.allocator, output); + defer freeGitStatusEntries(testing.allocator, entries); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("src/new.zig", entries[0].path); + try testing.expectEqual([2]u8{ 'R', ' ' }, entries[0].status); +} + +test "parseGitStatusPorcelain: blank lines and short lines are skipped" { + const output = "\nM \nM keep.zig\n\n"; + const entries = try parseGitStatusPorcelain(testing.allocator, output); + defer freeGitStatusEntries(testing.allocator, entries); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("keep.zig", entries[0].path); +} + +test "parseGitStatusPorcelain: empty output yields no entries" { + const entries = try parseGitStatusPorcelain(testing.allocator, ""); + defer freeGitStatusEntries(testing.allocator, entries); + try testing.expectEqual(@as(usize, 0), entries.len); +} + +test "parseSelectLines: drops blanks and trims carriage returns" { + const input = "src/main.zig\n\n \nREADME.md\r\n\tsrc/lib.zig\r\n"; + const items = try parseSelectLines(testing.allocator, input); + defer freeSelectLines(testing.allocator, items); + try testing.expectEqual(@as(usize, 3), items.len); + try testing.expectEqualStrings("src/main.zig", items[0]); + try testing.expectEqualStrings("README.md", items[1]); + try testing.expectEqualStrings("src/lib.zig", items[2]); +} + +test "parseSelectLines: empty input yields no candidates" { + const items = try parseSelectLines(testing.allocator, "\n \n"); + defer freeSelectLines(testing.allocator, items); + try testing.expectEqual(@as(usize, 0), items.len); +} + fn writeTestFile( directory: *std.Io.Dir, input_output_handle: std.Io, blob - 5ccc7e17a1b4c9ed61bbf8bd6464fed166e5b56d blob + 2bab06cf37cf060ccb8e1889d7f36c7720b56be6 --- src/picker.zig +++ src/picker.zig @@ -1130,6 +1130,7 @@ const GrepModel = struct { allocator: std.mem.Allocator, patterns: []const []const u8, theme: ResolvedTheme, + initial_query: ?[]const u8 = null, started_empty: bool = false, start_error: ?anyerror = null, output: ?[]u8 = null, @@ -1150,6 +1151,7 @@ const GrepModel = struct { allocator: std.mem.Allocator, patterns: []const []const u8, theme: ResolvedTheme, + initial_query: ?[]const u8, ) !*GrepModel { const self = try allocator.create(GrepModel); self.* = .{ @@ -1157,6 +1159,7 @@ const GrepModel = struct { .allocator = allocator, .patterns = patterns, .theme = theme, + .initial_query = initial_query, .list_view = .{ // SAFETY: Placeholder overwritten below before init returns. .children = .{ .builder = undefined }, @@ -1176,7 +1179,8 @@ const GrepModel = struct { } fn initialReload(self: *GrepModel) void { - const truncated = self.reload("") catch |err| { + const query = self.initial_query orelse ""; + const truncated = self.reload(query) catch |err| { self.started_empty = true; self.start_error = err; if (!builtin.is_test) { @@ -1547,6 +1551,7 @@ pub fn pickGrepLine( allocator: std.mem.Allocator, ignored_patterns: []const []const u8, theme: ResolvedTheme, + initial_query: ?[]const u8, ) ![]u8 { try checkTerminalSize(input_output.runtime()); { @@ -1567,8 +1572,21 @@ pub fn pickGrepLine( return error.UgrepNotFound; } } - const model = try GrepModel.init(input_output.runtime(), allocator, ignored_patterns, theme); + const model = try GrepModel.init( + input_output.runtime(), + allocator, + ignored_patterns, + theme, + initial_query, + ); defer model.deinit(); + // The first tick reloads from initial_query while the prompt widget shows + // only its own buffer, so the query goes to both places to stay in sync. + if (initial_query) |query| { + if (query.len > 0) { + try model.text_field.insertSliceAtCursor(query); + } + } var app_buffer: [4096]u8 = undefined; var app: vxfw.App = try .init( input_output.runtime(), @@ -2070,8 +2088,9 @@ test "GrepModel.init survives reload failure without d } else |_| {} } - var model = try GrepModel.init(testing.io, testing.allocator, &.{}, defaultTheme()); + var model = try GrepModel.init(testing.io, testing.allocator, &.{}, defaultTheme(), null); defer model.deinit(); + try testing.expect(model.initial_query == null); try testing.expect(model.pending_initial); model.initialReload(); try testing.expect(model.started_empty); @@ -2079,6 +2098,22 @@ test "GrepModel.init survives reload failure without d try testing.expectEqual(@as(usize, 0), model.lines.items.len); } +test "GrepModel initial query prefills prompt field" { + var model = try GrepModel.init( + testing.io, + testing.allocator, + &.{}, + defaultTheme(), + "needle", + ); + defer model.deinit(); + try testing.expectEqualStrings("needle", model.initial_query.?); + try model.text_field.insertSliceAtCursor(model.initial_query.?); + const content = try model.text_field.buf.toOwnedSlice(); + defer testing.allocator.free(content); + try testing.expectEqualStrings("needle", content); +} + test "parseThemeColor: default keyword" { const color = try parseThemeColor("default"); try testing.expect(color == .default);