diff --git a/src/leader.zig b/src/leader.zig index 65bfdbe..18e9ad0 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -21,7 +21,9 @@ pub const Action = union(enum) { quit, open: []u8, symbol: symbol_mod.Symbol, + file_picker, search_file, + search_project, repeat_rail, restore_last, not_built: Feature, @@ -127,6 +129,10 @@ pub const Leader = struct { self.mode = .open_prompt; return .none; } + if (std.mem.eql(u8, text, "f")) { + self.mode = .idle; + return .file_picker; + } if (std.mem.eql(u8, text, "p")) { self.mode = .symbol_rail; return .none; @@ -174,6 +180,7 @@ pub const Leader = struct { .text => |text| { self.mode = .idle; if (std.mem.eql(u8, text, "f")) return .search_file; + if (std.mem.eql(u8, text, "p")) return .search_project; self.message = "search target is not built in this profile yet"; return .{ .not_built = .search }; }, @@ -325,12 +332,7 @@ test "regular: search rail opens current-file search and rejects other targets" try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); try expectActionTag(.none, try leader.handleEvent(input.normalize("s"))); - const action = try leader.handleEvent(input.normalize("p")); - switch (action) { - .not_built => |feature| try std.testing.expectEqual(Feature.search, feature), - else => return error.ExpectedNotBuiltAction, - } - try std.testing.expectEqualStrings("search target is not built in this profile yet", leader.status()); + try expectActionTag(.search_project, try leader.handleEvent(input.normalize("p"))); } test "adversarial: unknown leader key does not dispatch and recovers to idle" { diff --git a/src/main.zig b/src/main.zig index 27ad35d..6241128 100644 --- a/src/main.zig +++ b/src/main.zig @@ -192,10 +192,22 @@ fn openDirectoryPreview(allocator: std.mem.Allocator, io: std.Io, client: *tui.C while (try iterator.next(io)) |entry| { if (count >= 80) break; if (entry.kind != .file and entry.kind != .directory) continue; - const suffix = if (entry.kind == .directory) "/" else ""; - const command = try std.fmt.allocPrint(allocator, "repo_file {s}{s}=", .{ entry.name, suffix }); - defer allocator.free(command); - client.handleTraceLine(command) catch {}; + const relative_path = try std.fmt.allocPrint(allocator, "{s}/{s}{s}", .{ path, entry.name, if (entry.kind == .directory) "/" else "" }); + defer allocator.free(relative_path); + if (entry.kind == .file) { + const bytes = std.Io.Dir.cwd().readFileAlloc(io, relative_path, allocator, .limited(diagnostics.max_file_bytes)) catch |err| switch (err) { + error.StreamTooLong, error.AccessDenied, error.FileNotFound, error.NotDir => null, + else => null, + }; + if (bytes) |content| { + defer allocator.free(content); + client.addRepoFileContent(relative_path, content) catch {}; + } else { + client.addRepoFileContent(relative_path, "") catch {}; + } + } else { + client.addRepoFileContent(relative_path, "") catch {}; + } count += 1; } try client.handleTraceLine("file_picker"); diff --git a/src/panel.zig b/src/panel.zig index 244e803..f69b30a 100644 --- a/src/panel.zig +++ b/src/panel.zig @@ -155,6 +155,14 @@ pub const Stack = struct { return list.selected.?; } + pub fn activeListItem(self: *const Stack) ![]const u8 { + const index = self.active_index orelse return Error.NoPanelOpen; + const panel = self.panels.items[index]; + if (panel.kind != .list or panel.list == null) return Error.ActivePanelIsNotList; + const list = panel.list.?; + return self.visibleItemAt(list.cursor) orelse Error.EmptyList; + } + pub fn cancelList(self: *Stack) !void { _ = try self.activeList(); try self.closeActive(); diff --git a/src/session.zig b/src/session.zig index c9fa555..4dedfbc 100644 --- a/src/session.zig +++ b/src/session.zig @@ -335,6 +335,10 @@ pub const Session = struct { return self.panels.selectList(); } + pub fn activeListItem(self: *const Session) ![]const u8 { + return self.panels.activeListItem(); + } + pub fn cancelListPanel(self: *Session) !void { try self.panels.cancelList(); } diff --git a/src/tui.zig b/src/tui.zig index 12cb9a8..34cf237 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -40,6 +40,12 @@ const PrefixRail = enum { replace, }; +const PanelContext = enum { + none, + file_picker, + project_search, +}; + const EditSnapshot = struct { bytes: []u8, cursor_byte: usize, @@ -140,6 +146,9 @@ pub const Client = struct { search_query: []u8 = &.{}, search_matches: std.ArrayList(usize) = .empty, search_index: usize = 0, + project_search_prompt_active: bool = false, + project_search_prompt: std.ArrayList(u8) = .empty, + panel_context: PanelContext = .none, undo_stack: std.ArrayList(EditSnapshot) = .empty, redo_stack: std.ArrayList(EditSnapshot) = .empty, yank_bytes: ?[]u8 = null, @@ -164,6 +173,7 @@ pub const Client = struct { if (self.saved_bytes) |bytes| self.allocator.free(bytes); if (self.yank_bytes) |bytes| self.allocator.free(bytes); self.search_prompt.deinit(self.allocator); + self.project_search_prompt.deinit(self.allocator); self.allocator.free(self.search_query); self.search_matches.deinit(self.allocator); self.freeSnapshotStack(&self.undo_stack); @@ -374,6 +384,7 @@ pub const Client = struct { self.message = null; if (self.search_prompt_active) return self.applySearchPromptInput(event); + if (self.project_search_prompt_active) return self.applyProjectSearchPromptInput(event); const leader_trigger = isLeaderTrigger(event) and self.effectiveMode() != .insert; if (self.leader.capturesInput() or leader_trigger) { @@ -425,13 +436,14 @@ pub const Client = struct { } fn effectiveMode(self: *const Client) EditorMode { - if (self.search_prompt_active or self.leader.isPromptActive()) return .prompt; + if (self.search_prompt_active or self.project_search_prompt_active or self.leader.isPromptActive()) return .prompt; const snap = self.session.snapshot() catch return self.mode; if (snap.active_panel_title != null) return .panel; return self.mode; } fn searchStatus(self: *const Client) []const u8 { + if (self.project_search_prompt_active) return "project search: type query, Enter accept, Esc cancel"; if (self.search_prompt_active) return "search: type query, Enter accept, Esc cancel"; if (self.search_query.len != 0) return "search active: n next N previous"; return ""; @@ -451,21 +463,24 @@ pub const Client = struct { }; } + pub fn addRepoFileContent(self: *Client, path: []const u8, file_bytes: []const u8) !void { + if (file_bytes.len > diagnostics_mod.max_file_bytes) { + self.message = "diagnostic:file_too_large"; + return Error.ProtocolRejected; + } + self.repo.addFile(path, file_bytes) catch |err| { + self.message = "diagnostic:unsupported_file"; + return err; + }; + self.message = null; + } + fn addRepoFile(self: *Client, payload: []const u8) !void { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { self.message = "diagnostic:unsupported_file:invalid_repo_file_payload"; return repo_mod.Error.InvalidPath; }; - const file_bytes = payload[separator + 1 ..]; - if (file_bytes.len > diagnostics_mod.max_file_bytes) { - self.message = "diagnostic:file_too_large"; - return Error.ProtocolRejected; - } - self.repo.addFile(payload[0..separator], file_bytes) catch |err| { - self.message = "diagnostic:unsupported_file"; - return err; - }; - self.message = null; + try self.addRepoFileContent(payload[0..separator], payload[separator + 1 ..]); } fn openRepoList(self: *Client, title: []const u8, tree: bool, include_ignored: bool) !void { @@ -904,16 +919,15 @@ pub const Client = struct { .text => |text| { if (std.mem.eql(u8, text, "j")) return self.applyProtocol("command list_down"); if (std.mem.eql(u8, text, "k")) return self.applyProtocol("command list_up"); - if (std.mem.eql(u8, text, "q")) { - self.message = "panel close is not built in this profile yet"; - return; - } + if (std.mem.eql(u8, text, "o")) return self.openActivePanelItem(); + if (std.mem.eql(u8, text, "q")) return self.closeActivePanel(); self.unknownPrefixOrInput("panel"); }, .key => |key| switch (key) { - .arrow_down => self.message = "panel down is not built in this profile yet", - .arrow_up => self.message = "panel up is not built in this profile yet", - .escape => self.message = "panel close is not built in this profile yet", + .arrow_down => try self.applyProtocol("command list_down"), + .arrow_up => try self.applyProtocol("command list_up"), + .enter => try self.openActivePanelItem(), + .escape => try self.closeActivePanel(), else => {}, }, .unknown => self.unknownPrefixOrInput("panel"), @@ -995,6 +1009,111 @@ pub const Client = struct { self.message = null; } + fn openFilePickerPanel(self: *Client) !void { + try self.openRepoList("files", false, false); + self.panel_context = .file_picker; + } + + fn openListRows(self: *Client, title: []const u8, rows: []const []const u8, context: PanelContext) !void { + if (rows.len == 0) { + self.message = "empty list"; + return; + } + var joined = std.ArrayList(u8).empty; + defer joined.deinit(self.allocator); + try joined.appendSlice(self.allocator, "list_open "); + try joined.appendSlice(self.allocator, title); + try joined.append(self.allocator, ' '); + for (rows, 0..) |row, i| { + if (i != 0) try joined.append(self.allocator, '|'); + try joined.appendSlice(self.allocator, row); + } + try self.applyProtocol(joined.items); + self.panel_context = context; + } + + fn openActivePanelItem(self: *Client) !void { + const item = self.session.activeListItem() catch |err| { + self.message = @errorName(err); + return; + }; + const owned = try self.allocator.dupe(u8, item); + defer self.allocator.free(owned); + switch (self.panel_context) { + .file_picker => try self.openRepoPathFromPanel(owned), + .project_search => try self.openProjectSearchResult(owned), + .none => self.message = "panel item has no action", + } + } + + fn closeActivePanel(self: *Client) !void { + try self.applyProtocol("command list_cancel"); + self.panel_context = .none; + } + + fn openRepoPathFromPanel(self: *Client, row: []const u8) !void { + const path = if (std.mem.startsWith(u8, row, "file ")) row[5..] else row; + const bytes = self.repo.content(path) catch |err| { + self.message = @errorName(err); + return; + }; + try self.session.openFixtureAt(bytes, 0); + try self.closeActivePanel(); + self.message = path; + } + + fn openProjectSearchResult(self: *Client, row: []const u8) !void { + const target = self.repo.searchOffsetFromRow(row) catch |err| { + self.message = @errorName(err); + return; + }; + const bytes = self.repo.content(target.path) catch |err| { + self.message = @errorName(err); + return; + }; + try self.session.openFixtureAt(bytes, target.offset); + try self.closeActivePanel(); + self.message = row; + } + + fn openProjectSearchPrompt(self: *Client) void { + self.project_search_prompt_active = true; + self.project_search_prompt.clearRetainingCapacity(); + self.message = null; + self.prefix = .none; + } + + fn applyProjectSearchPromptInput(self: *Client, event: input.Event) !void { + switch (event) { + .text => |text| try self.project_search_prompt.appendSlice(self.allocator, text), + .key => |key| switch (key) { + .enter => try self.commitProjectSearchPrompt(), + .escape => { + self.project_search_prompt_active = false; + self.message = "project search cancelled"; + }, + .backspace => { + if (self.project_search_prompt.items.len > 0) _ = self.project_search_prompt.pop(); + }, + .space => try self.project_search_prompt.append(self.allocator, ' '), + else => {}, + }, + .unknown => {}, + } + } + + fn commitProjectSearchPrompt(self: *Client) !void { + self.project_search_prompt_active = false; + const rows = try self.repo.searchRowsAlloc(self.allocator, self.project_search_prompt.items, false); + defer freeOwnedRows(self.allocator, rows); + if (rows.len == 0) { + self.message = "no project matches"; + return; + } + try self.openSearchList(self.project_search_prompt.items, false); + self.panel_context = .project_search; + } + fn openSearchPrompt(self: *Client) void { self.search_prompt_active = true; self.search_prompt.clearRetainingCapacity(); @@ -1442,7 +1561,9 @@ pub const Client = struct { try self.applyProtocol(line); }, .symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)), + .file_picker => try self.openFilePickerPanel(), .search_file => self.openSearchPrompt(), + .search_project => self.openProjectSearchPrompt(), .repeat_rail => self.openPrefix(.repeat), .restore_last => self.restoreLastRail(), .not_built => {}, @@ -3353,3 +3474,68 @@ test "adversarial: search no match cancel utf8 and long line remain safe" { snap = try client.session.snapshot(); try std.testing.expect(snap.cursor_byte > 20); } + +test "regular: file picker panel opens selected repo file" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); + defer client.deinit(); + try client.handleTraceLine("repo_file src/main.zig=const main = 1;"); + try client.handleTraceLine("repo_file src/lib.zig=const lib = 2;"); + + try client.handleInput(" "); + try client.handleInput("f"); + var snap = try client.session.snapshot(); + try std.testing.expect(snap.active_panel_title != null); + try client.handleInput("o"); + snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("const main = 1;", snap.bytes); + try std.testing.expect(snap.active_panel_title == null); +} + +test "regular: project search panel opens selected result at match" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); + defer client.deinit(); + try client.handleTraceLine("repo_file src/main.zig=alpha\nneedle here\n"); + try client.handleTraceLine("repo_file src/lib.zig=other\n"); + + try client.handleInput(" "); + try client.handleInput("s"); + try client.handleInput("p"); + try std.testing.expectEqualStrings("prompt", client.modeName()); + try client.handleInput("n"); + try client.handleInput("e"); + try client.handleInput("e"); + try client.handleInput("d"); + try client.handleInput("l"); + try client.handleInput("e"); + try client.handleInput("\n"); + var snap = try client.session.snapshot(); + try std.testing.expect(snap.active_panel_title != null); + try client.handleInput("o"); + snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("alpha\nneedle here\n", snap.bytes); + try std.testing.expect(snap.cursor_byte >= 6); +} + +test "adversarial: file and project panels handle ignored empty missing and close" { + var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); + defer client.deinit(); + try client.handleTraceLine("repo_file src/main.zig=visible"); + + try client.handleInput(" "); + try client.handleInput("f"); + const picker_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(picker_frame); + try std.testing.expect(std.mem.indexOf(u8, picker_frame, "src/main.zig") != null); + try client.handleInput("q"); + const snap = try client.session.snapshot(); + try std.testing.expect(snap.active_panel_title == null); + + try client.handleInput(" "); + try client.handleInput("s"); + try client.handleInput("p"); + try client.handleInput("missing"); + try client.handleInput("\n"); + const no_match_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(no_match_frame); + try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no project matches") != null); +}