Commit Diff


commit - 2041a691fc814e962e906653821c729b17d3284f
commit + 635534fb96c0fa7f53eb33f82d3f846d30290e0f
blob - c61b4bb40e8d5eab8df4f7620c62914300444eed
blob + ae284dfd353c14f6b2259cce4de3298ef58bdfcf
--- README.md
+++ README.md
@@ -37,7 +37,9 @@ magdalena log-file <path> [type] [action]
 magdalena search <query>
 magdalena look-file [--depth <n>]
 magdalena look-directory [--depth <n>]
-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 <query>
         \\  magdalena look-file [--depth <n>]
         \\  magdalena look-directory [--depth <n>]
-        \\  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);