Quick-jump tool made in Zig

pp: 1.0.0

Reads a list of project directories from
~/.config/project-picker/projects and presents the items in a list that
can be filtered based on user input.

Intended functionality is to feed this to something like `cd` to quickly
jump between some predefined list of directories.

+492
+2
.gitignore
··· 1 + .zig-cache 2 + zig-out
+26
LICENSE
··· 1 + Copyright 2024 Kristófer Reykjalín 2 + 3 + Redistribution and use in source and binary forms, with or without modification, 4 + are permitted provided that the following conditions are met: 5 + 6 + 1. Redistributions of source code must retain the above copyright notice, this 7 + list of conditions and the following disclaimer. 8 + 9 + 2. Redistributions in binary form must reproduce the above copyright notice, 10 + this list of conditions and the following disclaimer in the documentation 11 + and/or other materials provided with the distribution. 12 + 13 + 3. Neither the name of the copyright holder nor the names of its contributors 14 + may be used to endorse or promote products derived from this software 15 + without specific prior written permission. 16 + 17 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” 18 + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 19 + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 21 + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 + ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+67
README.md
··· 1 + # Project picker 2 + 3 + Presents a filterable list of pre-defined strings from `~/.config/project-picker/projects` for you to select and provide to some shell command. 4 + Once an item in the list is selected `project-picker` will print the full path to STDOUT. 5 + 6 + **Exit codes:** 7 + 8 + * If an item is picked `project-picker` will exit with status `0`. 9 + * If no item is chosen `project-picker` will exit with status `1`. 10 + * If `Ctrl-C` is used to close `project-picker` instead of selecting an item it will exit with status `1`. 11 + * If an error occurred `project-picker` will exit with status `74` and an error message is printed to STDERR. 12 + 13 + ## Usage example: jump between project folders 14 + 15 + Add a list of the projects you want easily available to a config file, then run `project-picker`. 16 + `project-picker` will print the selected project path to STDOUT. 17 + 18 + ### Add project paths to the config file 19 + 20 + ``` 21 + ~/projects/project-a 22 + ~/projects/project-b 23 + /Users/jdoe/projects/project-c 24 + ``` 25 + 26 + ### Example: jump between projects with cd 27 + 28 + Put in a shell script or alias: 29 + 30 + ```fish 31 + # ~/.config/fish/functions/pp.fish 32 + # Fish alias for project-picker, usage: pp 33 + function pp 34 + set dir (project-picker) 35 + 36 + # A non-zero exit code means no project was selected. 37 + if test $status -eq 0 38 + cd $dir 39 + end 40 + end 41 + ``` 42 + 43 + 44 + ## Usage example: pass result to command 45 + 46 + This is the quick and dirty way to use `project-picker`. 47 + If an error occurs or you don't pick an item nothing will be passed to the command. 48 + 49 + ```bash 50 + # `cd` to selected item. 51 + cd $(project-picker) 52 + 53 + # Open selected item in vim. 54 + vim $(project-picker) 55 + ``` 56 + 57 + 58 + ## Build `project-picker` 59 + 60 + ```sh 61 + # Will place `project-picker` in ./zig-out/bin 62 + zig build -Doptimize=ReleaseSafe 63 + 64 + # Will place `project-picker` in ~/.local/bin 65 + zig build -Doptimize=ReleaseSafe --prefix ~/.local 66 + ``` 67 +
+49
build.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn build(b: *std.Build) void { 4 + const target = b.standardTargetOptions(.{}); 5 + const optimize = b.standardOptimizeOption(.{}); 6 + 7 + const known_folders_dep = b.dependency("known-folders", .{ 8 + .target = target, 9 + .optimize = optimize, 10 + }); 11 + 12 + const vaxis_dep = b.dependency("vaxis", .{ 13 + .target = target, 14 + .optimize = optimize, 15 + }); 16 + 17 + const exe = b.addExecutable(.{ 18 + .name = "project-picker", 19 + .root_source_file = b.path("src/main.zig"), 20 + .target = target, 21 + .optimize = optimize, 22 + }); 23 + 24 + exe.root_module.addImport("known-folders", known_folders_dep.module("known-folders")); 25 + exe.root_module.addImport("vaxis", vaxis_dep.module("vaxis")); 26 + 27 + b.installArtifact(exe); 28 + 29 + const run_cmd = b.addRunArtifact(exe); 30 + run_cmd.step.dependOn(b.getInstallStep()); 31 + 32 + if (b.args) |args| { 33 + run_cmd.addArgs(args); 34 + } 35 + 36 + const run_step = b.step("run", "Run the app"); 37 + run_step.dependOn(&run_cmd.step); 38 + 39 + const exe_unit_tests = b.addTest(.{ 40 + .root_source_file = b.path("src/main.zig"), 41 + .target = target, 42 + .optimize = optimize, 43 + }); 44 + 45 + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); 46 + 47 + const test_step = b.step("test", "Run unit tests"); 48 + test_step.dependOn(&run_exe_unit_tests.step); 49 + }
+22
build.zig.zon
··· 1 + .{ 2 + .name = "project-picker", 3 + .version = "1.0.0", 4 + .minimum_zig_version = "0.13.0", 5 + .dependencies = .{ 6 + .vaxis = .{ 7 + .url = "https://github.com/rockorager/libvaxis/archive/refs/heads/main.zip", 8 + .hash = "12201432ebbf5a7eb210ef1df546b32c9465d2d536d69891ba76d559c051047d251e", 9 + }, 10 + .@"known-folders" = .{ 11 + .url = "git+https://github.com/ziglibs/known-folders.git#1cceeb70e77dec941a4178160ff6c8d05a74de6f", 12 + .hash = "12205f5e7505c96573f6fc5144592ec38942fb0a326d692f9cddc0c7dd38f9028f29", 13 + }, 14 + }, 15 + .paths = .{ 16 + "build.zig", 17 + "build.zig.zon", 18 + "src", 19 + "LICENSE", 20 + "README.md", 21 + }, 22 + }
+326
src/main.zig
··· 1 + // Heavily based on the fuzzy example from libvaxis: 2 + // https://github.com/rockorager/libvaxis/blob/5a8112b78be7f8c52d7404a28d997f0638d1c665/examples/fuzzy.zig 3 + 4 + const std = @import("std"); 5 + const kf = @import("known-folders"); 6 + const vaxis = @import("vaxis"); 7 + const vxfw = vaxis.vxfw; 8 + 9 + pub const known_folders_config: kf.KnownFolderConfig = .{ 10 + .xdg_on_mac = true, 11 + }; 12 + 13 + const ProjectPicker = struct { 14 + /// The full list of available items. 15 + list: std.ArrayList(vxfw.Text), 16 + /// The filtered list of available items. 17 + filtered: std.ArrayList(vxfw.RichText), 18 + /// The ListView used to render the filtered list of items. 19 + list_view: vxfw.ListView, 20 + /// The input box to type in a search pattern. 21 + text_field: vxfw.TextField, 22 + 23 + /// Used to allocate RichText widgets in the ListView. 24 + arena: std.heap.ArenaAllocator, 25 + 26 + /// Stores the selected path. 27 + result: std.ArrayList(u8), 28 + 29 + pub fn widget(self: *ProjectPicker) vxfw.Widget { 30 + return .{ 31 + .userdata = self, 32 + .eventHandler = ProjectPicker.typeErasedEventHandler, 33 + .drawFn = ProjectPicker.typeErasedDrawFn, 34 + }; 35 + } 36 + 37 + pub fn eventHandler( 38 + self: *ProjectPicker, 39 + ctx: *vxfw.EventContext, 40 + event: vxfw.Event, 41 + ) anyerror!void { 42 + switch (event) { 43 + .init => { 44 + // Initialize the filtered list 45 + const allocator = self.arena.allocator(); 46 + for (self.list.items) |line| { 47 + var spans = std.ArrayList(vxfw.RichText.TextSpan).init(allocator); 48 + const span: vxfw.RichText.TextSpan = .{ .text = line.text }; 49 + try spans.append(span); 50 + try self.filtered.append(.{ .text = spans.items }); 51 + } 52 + 53 + return ctx.requestFocus(self.text_field.widget()); 54 + }, 55 + .key_press => |key| { 56 + if (key.matches('c', .{ .ctrl = true })) { 57 + ctx.quit = true; 58 + return; 59 + } 60 + 61 + try self.list_view.handleEvent(ctx, event); 62 + }, 63 + .focus_in => { 64 + return ctx.requestFocus(self.text_field.widget()); 65 + }, 66 + else => {}, 67 + } 68 + } 69 + 70 + pub fn draw( 71 + self: *ProjectPicker, 72 + ctx: vxfw.DrawContext, 73 + ) std.mem.Allocator.Error!vxfw.Surface { 74 + const max = ctx.max.size(); 75 + 76 + var list_view: vxfw.SubSurface = .{ 77 + .origin = .{ .row = 2, .col = 0 }, 78 + .surface = try self.list_view.draw(ctx.withConstraints( 79 + ctx.min, 80 + .{ .width = max.width, .height = max.height - 3 }, 81 + )), 82 + }; 83 + list_view.surface.focusable = false; 84 + 85 + const text_field: vxfw.SubSurface = .{ 86 + .origin = .{ .row = 0, .col = 2 }, 87 + .surface = try self.text_field.draw(ctx.withConstraints( 88 + ctx.min, 89 + .{ .width = max.width, .height = 1 }, 90 + )), 91 + }; 92 + 93 + const prompt: vxfw.Text = .{ .text = "", .style = .{ .fg = .{ .index = 4 } } }; 94 + 95 + const prompt_surface: vxfw.SubSurface = .{ 96 + .origin = .{ .row = 0, .col = 0 }, 97 + .surface = try prompt.draw(ctx.withConstraints(ctx.min, .{ .width = 2, .height = 1 })), 98 + }; 99 + 100 + const children = try ctx.arena.alloc(vxfw.SubSurface, 3); 101 + children[0] = list_view; 102 + children[1] = text_field; 103 + children[2] = prompt_surface; 104 + 105 + return .{ 106 + .size = max, 107 + .widget = self.widget(), 108 + .focusable = true, 109 + .buffer = &.{}, 110 + .children = children, 111 + }; 112 + } 113 + 114 + fn typeErasedEventHandler( 115 + ptr: *anyopaque, 116 + ctx: *vxfw.EventContext, 117 + event: vxfw.Event, 118 + ) anyerror!void { 119 + const self: *ProjectPicker = @ptrCast(@alignCast(ptr)); 120 + try self.eventHandler(ctx, event); 121 + } 122 + 123 + fn typeErasedDrawFn( 124 + ptr: *anyopaque, 125 + ctx: vxfw.DrawContext, 126 + ) std.mem.Allocator.Error!vxfw.Surface { 127 + const self: *ProjectPicker = @ptrCast(@alignCast(ptr)); 128 + return try self.draw(ctx); 129 + } 130 + 131 + pub fn widget_builder(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget { 132 + const self: *const ProjectPicker = @ptrCast(@alignCast(ptr)); 133 + if (idx >= self.filtered.items.len) return null; 134 + 135 + return self.filtered.items[idx].widget(); 136 + } 137 + 138 + pub fn on_change(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, str: []const u8) anyerror!void { 139 + const ptr = maybe_ptr orelse return; 140 + const self: *ProjectPicker = @ptrCast(@alignCast(ptr)); 141 + 142 + // Clear the filtered list and the arena. 143 + self.filtered.clearAndFree(); 144 + _ = self.arena.reset(.free_all); 145 + 146 + const allocator = self.arena.allocator(); 147 + 148 + // If there is text in the search box we only render items that contain the search string. 149 + // Otherwise we render all the items. 150 + if (str.len > 0) { 151 + for (self.list.items) |item| { 152 + if (std.mem.containsAtLeast(u8, item.text, 1, str)) { 153 + // FIXME: add more spans that highlight the matching text. 154 + var spans = std.ArrayList(vxfw.RichText.TextSpan).init(allocator); 155 + const span: vxfw.RichText.TextSpan = .{ .text = item.text }; 156 + try spans.append(span); 157 + try self.filtered.append(.{ .text = spans.items }); 158 + } 159 + } 160 + } else { 161 + for (self.list.items) |line| { 162 + var spans = std.ArrayList(vxfw.RichText.TextSpan).init(allocator); 163 + const span: vxfw.RichText.TextSpan = .{ .text = line.text }; 164 + try spans.append(span); 165 + try self.filtered.append(.{ .text = spans.items }); 166 + } 167 + } 168 + 169 + self.list_view.scroll.top = 0; 170 + self.list_view.scroll.offset = 0; 171 + self.list_view.cursor = 0; 172 + } 173 + 174 + pub fn on_submit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void { 175 + const ptr = maybe_ptr orelse return; 176 + const self: *ProjectPicker = @ptrCast(@alignCast(ptr)); 177 + 178 + const arena = self.arena.allocator(); 179 + self.result.clearAndFree(); 180 + 181 + // 1. We want to quit on every submit, even ones that fail. 182 + 183 + ctx.quit = true; 184 + 185 + // 2. Get the selected item. 186 + 187 + if (self.list_view.cursor >= self.filtered.items.len) return; 188 + 189 + var selected_item = std.ArrayList(u8).init(arena); 190 + defer selected_item.deinit(); 191 + 192 + for (self.filtered.items[self.list_view.cursor].text) |span| { 193 + try selected_item.appendSlice(span.text); 194 + } 195 + 196 + // 3. If we can find a home directory replace any `~` in the chosen path with the path to 197 + // the home directory. 198 + 199 + const home_path = kf.getPath(arena, .home) catch null; 200 + if (home_path) |home| { 201 + const replace_len = std.mem.replacementSize(u8, selected_item.items, "~", home); 202 + const result = try arena.alloc(u8, replace_len); 203 + 204 + _ = std.mem.replace( 205 + u8, 206 + selected_item.items, 207 + "~", 208 + home, 209 + result, 210 + ); 211 + 212 + try self.result.appendSlice(result); 213 + return; 214 + } 215 + 216 + // 4. Otherwise just return the chosen item unmodified. 217 + 218 + try self.result.appendSlice(selected_item.items); 219 + } 220 + }; 221 + 222 + pub fn main() !void { 223 + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 224 + defer _ = gpa.deinit(); 225 + 226 + const allocator = gpa.allocator(); 227 + 228 + var app = try vxfw.App.init(allocator); 229 + errdefer app.deinit(); 230 + 231 + const picker = try allocator.create(ProjectPicker); 232 + defer allocator.destroy(picker); 233 + 234 + picker.* = .{ 235 + .list = std.ArrayList(vxfw.Text).init(allocator), 236 + .filtered = std.ArrayList(vxfw.RichText).init(allocator), 237 + .list_view = .{ 238 + .children = .{ 239 + .builder = .{ 240 + .userdata = picker, 241 + .buildFn = ProjectPicker.widget_builder, 242 + }, 243 + }, 244 + }, 245 + .text_field = .{ 246 + .buf = vxfw.TextField.Buffer.init(allocator), 247 + .unicode = &app.vx.unicode, 248 + .userdata = picker, 249 + .onChange = ProjectPicker.on_change, 250 + .onSubmit = ProjectPicker.on_submit, 251 + }, 252 + .arena = std.heap.ArenaAllocator.init(allocator), 253 + .result = std.ArrayList(u8).init(allocator), 254 + }; 255 + defer picker.text_field.deinit(); 256 + defer picker.list.deinit(); 257 + defer picker.filtered.deinit(); 258 + defer picker.arena.deinit(); 259 + defer picker.result.deinit(); 260 + 261 + // 1. Open the ~/.config directory, or equivalent on Windows. 262 + 263 + const config_dir = kf.open( 264 + allocator, 265 + .local_configuration, 266 + .{ .access_sub_paths = true }, 267 + ) catch |err| { 268 + std.log.err("failed to open config directory: {}", .{err}); 269 + std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file. 270 + }; 271 + 272 + if (config_dir) |config| { 273 + // 2. Read the contents of the ~/.config/project-picker/projects file. 274 + 275 + const pp_dir = config.makeOpenPath("project-picker", .{}) catch |err| { 276 + std.log.err("failed to open project-picker config directory: {}", .{err}); 277 + std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file. 278 + }; 279 + 280 + const projects_file = pp_dir.createFile( 281 + "projects", 282 + .{ .truncate = false, .read = true }, 283 + ) catch |err| { 284 + std.log.err("failed to load project-picker project file: {}", .{err}); 285 + std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file. 286 + }; 287 + 288 + const projects = projects_file.reader().readAllAlloc( 289 + allocator, 290 + std.math.maxInt(usize), 291 + ) catch |err| { 292 + std.log.err("failed to read project-picker project file: {}", .{err}); 293 + std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file. 294 + }; 295 + defer allocator.free(projects); 296 + 297 + // 3. Parse the lines of the file and add them to the available items in the project picker. 298 + 299 + var it = std.mem.tokenizeScalar(u8, projects, '\n'); 300 + while (it.next()) |token| { 301 + try picker.list.append(.{ .text = token }); 302 + } 303 + 304 + // 4. Run the picker. 305 + 306 + try app.run(picker.widget(), .{}); 307 + app.deinit(); 308 + 309 + // 5. If no selection was made exit with $status == 1. 310 + 311 + if (picker.result.items.len == 0) { 312 + std.process.exit(1); 313 + } 314 + 315 + // 6. Print the chosen path to STDOUT. 316 + 317 + const stdout = std.io.getStdOut().writer(); 318 + nosuspend stdout.print("{s}", .{picker.result.items}) catch |err| { 319 + std.log.err("{s}", .{@errorName(err)}); 320 + std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file. 321 + }; 322 + } else { 323 + std.log.err("failed to open config directory", .{}); 324 + std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file. 325 + } 326 + }