commit - 2041a691fc814e962e906653821c729b17d3284f
commit + 635534fb96c0fa7f53eb33f82d3f846d30290e0f
blob - c61b4bb40e8d5eab8df4f7620c62914300444eed
blob + ae284dfd353c14f6b2259cce4de3298ef58bdfcf
--- README.md
+++ README.md
magdalena search <query>
magdalena look-file [--depth <n>]
magdalena look-directory [--depth <n>]
-magdalena grep
+magdalena grep [query]
+magdalena git-status
+magdalena select
```
### Options
# 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
recent_directories,
recent_files,
favorites,
+ git_status,
search,
+ select,
goto_directory,
goto_file,
look_file,
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 },
.{ .name = "recent-directories", .action = .recent_directories },
.{ .name = "recent-files", .action = .recent_files },
.{ .name = "search", .action = .search },
+ .{ .name = "select", .action = .select },
};
comptime {
.search => {
parsed_arguments.query = argument;
},
+ .grep => {
+ parsed_arguments.query = argument;
+ },
.log_directory => {
parsed_arguments.log_path = argument;
},
\\ 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
.{ .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 });
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
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
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 {
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);
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,
);
}
+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
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,
allocator: std.mem.Allocator,
patterns: []const []const u8,
theme: ResolvedTheme,
+ initial_query: ?[]const u8,
) !*GrepModel {
const self = try allocator.create(GrepModel);
self.* = .{
.allocator = allocator,
.patterns = patterns,
.theme = theme,
+ .initial_query = initial_query,
.list_view = .{
// SAFETY: Placeholder overwritten below before init returns.
.children = .{ .builder = undefined },
}
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) {
allocator: std.mem.Allocator,
ignored_patterns: []const []const u8,
theme: ResolvedTheme,
+ initial_query: ?[]const u8,
) ![]u8 {
try checkTerminalSize(input_output.runtime());
{
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(),
} 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);
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);