fzf.zig (18965B)
1 const std = @import("std"); 2 3 const cache = @import("memcached.zig"); 4 const cfg = @import("cfg.zig"); 5 const hist = @import("db.zig"); 6 const io = @import("io.zig"); 7 8 fn gotoFile(allocator: std.mem.Allocator, db: ?*hist.Db, file_path: []const u8, line: ?[]const u8, config: *const cfg.Config) !void { 9 const ext = if (std.mem.lastIndexOfScalar(u8, file_path, '.')) |idx| file_path[idx + 1 ..] else ""; 10 11 var action: []const u8 = "text_editor"; 12 var command: ?[]const u8 = null; 13 14 for (config.openers) |opener| { 15 for (opener.extensions) |e| { 16 if (std.mem.eql(u8, e, ext)) { 17 action = opener.action; 18 command = opener.command; 19 break; 20 } 21 } 22 if (command != null) break; 23 } 24 25 if (db) |d| { 26 d.logFile(file_path, ext, action) catch |err| io.warn("failed to log file: {}\n", .{err}); 27 } 28 29 if (std.Io.Dir.openFileAbsolute(io.rt(), "/dev/tty", .{ .mode = .read_write })) |tty| { 30 if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) io.warn("failed to redirect stdin to tty\n", .{}); 31 if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) io.warn("failed to redirect stdout to tty\n", .{}); 32 tty.close(io.rt()); 33 } else |err| { 34 io.warn("failed to open /dev/tty: {}\n", .{err}); 35 } 36 37 if (command) |cmd| { 38 return std.process.replace(io.rt(), .{ .argv = &.{ cmd, file_path } }); 39 } 40 41 const editor = config.editor orelse (io.getenv("EDITOR") orelse return error.EditorNotSet); 42 43 if (line) |l| { 44 const line_arg = try std.fmt.allocPrint(allocator, "+{s}", .{l}); 45 return std.process.replace(io.rt(), .{ .argv = &.{ editor, line_arg, file_path } }); 46 } 47 48 return std.process.replace(io.rt(), .{ .argv = &.{ editor, file_path } }); 49 } 50 51 const StdIo = std.process.SpawnOptions.StdIo; 52 53 const Fzf = struct { 54 argv: [][]const u8, 55 allocator: std.mem.Allocator, 56 stdin: StdIo = .pipe, 57 stdout: StdIo = .pipe, 58 stderr: StdIo = .inherit, 59 60 pub fn init(allocator: std.mem.Allocator, fzf_opts: []const []const u8, extra_opts: []const []const u8) !Fzf { 61 var argv = try allocator.alloc([]const u8, 1 + fzf_opts.len + extra_opts.len); 62 errdefer allocator.free(argv); 63 64 var i: usize = 0; 65 argv[i] = "fzf"; 66 i += 1; 67 for (fzf_opts) |opt| { 68 argv[i] = opt; 69 i += 1; 70 } 71 for (extra_opts) |opt| { 72 argv[i] = opt; 73 i += 1; 74 } 75 76 return .{ 77 .argv = argv, 78 .allocator = allocator, 79 }; 80 } 81 82 pub fn spawn(self: *Fzf) !std.process.Child { 83 return std.process.spawn(io.rt(), .{ 84 .argv = self.argv, 85 .stdin = self.stdin, 86 .stdout = self.stdout, 87 .stderr = self.stderr, 88 }); 89 } 90 91 pub fn deinit(self: *Fzf) void { 92 self.allocator.free(self.argv); 93 } 94 }; 95 96 // ignored_patterns entries are matched by exact path component name, not globs. 97 fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool { 98 var it = std.mem.splitScalar(u8, path, std.fs.path.sep); 99 while (it.next()) |component| { 100 for (ignored_patterns) |pattern| { 101 if (std.mem.eql(u8, component, pattern)) { 102 return true; 103 } 104 } 105 } 106 return false; 107 } 108 109 fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, items: []const []const u8) ![]u8 { 110 var fzf = try Fzf.init(allocator, fzf_opts, &[_][]const u8{}); 111 defer fzf.deinit(); 112 var child = try fzf.spawn(); 113 114 if (child.stdin) |stdin| { 115 for (items) |item| { 116 stdin.writeStreamingAll(io.rt(), item) catch |err| { 117 if (err == error.BrokenPipe) break; 118 return err; 119 }; 120 stdin.writeStreamingAll(io.rt(), "\n") catch |err| { 121 if (err == error.BrokenPipe) break; 122 return err; 123 }; 124 } 125 stdin.close(io.rt()); 126 child.stdin = null; 127 } 128 129 var stdout_data: ?[]u8 = null; 130 defer if (stdout_data) |d| allocator.free(d); 131 132 if (child.stdout) |stdout| { 133 var buffer: [4096]u8 = undefined; 134 var sr = stdout.reader(io.rt(), &buffer); 135 stdout_data = try sr.interface.allocRemaining(allocator, .unlimited); 136 } 137 138 const term = try child.wait(io.rt()); 139 if (term != .exited or term.exited != 0) return error.UserAbort; 140 const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t"); 141 if (selected.len == 0) return error.UserAbort; 142 143 return allocator.dupe(u8, selected); 144 } 145 146 pub fn recentDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { 147 const managed_dirs = try db.recentDirs(); 148 defer managed_dirs.deinit(); 149 const dirs = managed_dirs.value; 150 151 if (dirs.len == 0) { 152 io.warn("No recent directories found.\n", .{}); 153 return; 154 } 155 156 var items = std.ArrayListUnmanaged([]const u8).empty; 157 defer items.deinit(allocator); 158 159 for (dirs) |dir| { 160 if (shouldSkip(dir.path, config.ignored_patterns)) continue; 161 try items.append(allocator, dir.path); 162 } 163 164 const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); 165 defer allocator.free(selected); 166 167 std.Io.Dir.accessAbsolute(io.rt(), selected, .{}) catch |err| { 168 io.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err }); 169 return error.UserAbort; 170 }; 171 172 var out_buf: [4096]u8 = undefined; 173 var out = io.getStdout(&out_buf); 174 try out.print("{s}\n", .{selected}); 175 try out.end(); 176 } 177 178 pub fn favorites(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { 179 if (config.favorites.len == 0) { 180 io.warn("No favorites found in config.\n", .{}); 181 return; 182 } 183 184 var items = std.ArrayListUnmanaged([]const u8).empty; 185 defer { 186 for (items.items) |item| allocator.free(item); 187 items.deinit(allocator); 188 } 189 190 for (config.favorites) |entry| { 191 const expanded = try expandPath(allocator, entry); 192 try items.append(allocator, expanded); 193 } 194 195 const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); 196 defer allocator.free(selected); 197 198 const is_dir = blk: { 199 var dir = std.Io.Dir.openDirAbsolute(io.rt(), selected, .{}) catch break :blk false; 200 dir.close(io.rt()); 201 break :blk true; 202 }; 203 204 if (is_dir) { 205 try db.logDir(selected); 206 var out_buf: [4096]u8 = undefined; 207 var out = io.getStdout(&out_buf); 208 try out.print("{s}\n", .{selected}); 209 try out.end(); 210 } else { 211 try gotoFile(allocator, db, selected, null, config); 212 } 213 } 214 215 fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { 216 var res = std.ArrayListUnmanaged(u8).empty; 217 errdefer res.deinit(allocator); 218 219 var i: usize = 0; 220 while (i < path.len) { 221 if (std.mem.startsWith(u8, path[i..], "$HOME")) { 222 const home = io.getenv("HOME") orelse "/home/user"; 223 try res.appendSlice(allocator, home); 224 i += 5; 225 } else { 226 try res.append(allocator, path[i]); 227 i += 1; 228 } 229 } 230 231 return res.toOwnedSlice(allocator); 232 } 233 234 pub fn recentFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { 235 const managed_files = try db.recentFiles(); 236 defer managed_files.deinit(); 237 const files = managed_files.value; 238 239 if (files.len == 0) { 240 io.warn("No recent files found.\n", .{}); 241 return; 242 } 243 244 var items = std.ArrayListUnmanaged([]const u8).empty; 245 defer items.deinit(allocator); 246 247 for (files) |file| { 248 if (shouldSkip(file.path, config.ignored_patterns)) continue; 249 try items.append(allocator, file.path); 250 } 251 252 const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); 253 defer allocator.free(selected); 254 255 try std.Io.Dir.accessAbsolute(io.rt(), selected, .{}); 256 257 try gotoFile(allocator, db, selected, null, config); 258 } 259 260 pub fn grep(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { 261 const initial_query = ""; 262 263 var rg_cmd = std.ArrayListUnmanaged(u8).empty; 264 defer rg_cmd.deinit(allocator); 265 try rg_cmd.appendSlice(allocator, "change:reload:rg --column --line-number --no-heading --color=always --smart-case"); 266 for (config.ignored_patterns) |pattern| { 267 try rg_cmd.appendSlice(allocator, " --glob '!"); 268 try rg_cmd.appendSlice(allocator, pattern); 269 try rg_cmd.appendSlice(allocator, "/*'"); 270 try rg_cmd.appendSlice(allocator, " --glob '!"); 271 try rg_cmd.appendSlice(allocator, pattern); 272 try rg_cmd.appendSlice(allocator, "'"); 273 } 274 try rg_cmd.appendSlice(allocator, " {q} || true"); 275 276 var fzf = try Fzf.init(allocator, config.fzf_opts, &[_][]const u8{ 277 "--disabled", 278 "--query", 279 initial_query, 280 "--bind", 281 rg_cmd.items, 282 "--delimiter", 283 ":", 284 "--preview", 285 "bat --highlight-line {2} {1}", 286 "--preview-window", 287 "right,60%,border-left,+{2}+3/3,~3", 288 }); 289 defer fzf.deinit(); 290 291 fzf.stdin = .inherit; 292 fzf.stdout = .pipe; 293 fzf.stderr = .inherit; 294 295 var child = try fzf.spawn(); 296 297 var stdout_data: ?[]u8 = null; 298 defer if (stdout_data) |d| allocator.free(d); 299 300 if (child.stdout) |stdout| { 301 var buffer: [4096]u8 = undefined; 302 var sr = stdout.reader(io.rt(), &buffer); 303 stdout_data = try sr.interface.allocRemaining(allocator, .unlimited); 304 } 305 306 const term = try child.wait(io.rt()); 307 if (term != .exited or term.exited != 0) return error.UserAbort; 308 const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t"); 309 if (selected.len == 0) return error.UserAbort; 310 311 var it = std.mem.splitScalar(u8, selected, ':'); 312 if (it.next()) |file| { 313 const line = it.next(); 314 try gotoFile(allocator, db, file, line, config); 315 } 316 } 317 318 pub fn lookFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void { 319 try runFzfWithWalker(allocator, db, .file, config, depth); 320 } 321 322 pub fn lookDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void { 323 try runFzfWithWalker(allocator, db, .directory, config, depth); 324 } 325 326 const Entry = struct { 327 path: []const u8, 328 mtime: i128, 329 }; 330 331 fn getMaxDirMtime(ignored_patterns: []const []const u8, max_depth: usize) !i128 { 332 var max_mtime: i128 = 0; 333 334 if (std.Io.Dir.cwd().statFile(io.rt(), ".", .{})) |stat| { 335 max_mtime = stat.mtime.nanoseconds; 336 } else |_| {} 337 338 var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true }); 339 defer root.close(io.rt()); 340 try scanMaxMtime(&root, 0, max_depth, ignored_patterns, &max_mtime); 341 342 return max_mtime; 343 } 344 345 // Recurses one open directory per call frame, so each handle is closed exactly 346 // once by its own `defer` as the recursion unwinds. 347 fn scanMaxMtime(dir: *std.Io.Dir, depth: usize, max_depth: usize, ignored_patterns: []const []const u8, max_mtime: *i128) !void { 348 var it = dir.iterate(); 349 while (try it.next(io.rt())) |entry| { 350 var skip = false; 351 for (ignored_patterns) |pattern| { 352 if (std.mem.eql(u8, entry.name, pattern)) { 353 skip = true; 354 break; 355 } 356 } 357 if (skip) continue; 358 359 const is_dir = entry.kind == .directory; 360 if (is_dir or entry.kind == .sym_link) { 361 const stat = dir.statFile(io.rt(), entry.name, .{}) catch |err| { 362 if (err != error.FileNotFound and err != error.AccessDenied) { 363 io.warn("failed to stat {s}: {}\n", .{ entry.name, err }); 364 } 365 continue; 366 }; 367 if (stat.kind == .directory) { 368 if (stat.mtime.nanoseconds > max_mtime.*) max_mtime.* = stat.mtime.nanoseconds; 369 370 if (is_dir and depth + 1 < max_depth) { 371 var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| { 372 io.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); 373 continue; 374 }; 375 defer sub_dir.close(io.rt()); 376 try scanMaxMtime(&sub_dir, depth + 1, max_depth, ignored_patterns, max_mtime); 377 } 378 } 379 } 380 } 381 } 382 383 // Recurses one open directory per call frame (closed by its own `defer`), 384 // collecting matching entries with their path relative to the walk root. 385 fn collectEntries( 386 allocator: std.mem.Allocator, 387 dir: *std.Io.Dir, 388 prefix: ?[]const u8, 389 depth: usize, 390 max_depth: usize, 391 kind: std.Io.File.Kind, 392 ignored_patterns: []const []const u8, 393 entries: *std.ArrayListUnmanaged(Entry), 394 ) !void { 395 var it = dir.iterate(); 396 while (try it.next(io.rt())) |entry| { 397 const entry_path = if (prefix) |p| 398 try std.fs.path.join(allocator, &.{ p, entry.name }) 399 else 400 try allocator.dupe(u8, entry.name); 401 defer allocator.free(entry_path); 402 403 if (shouldSkip(entry_path, ignored_patterns)) continue; 404 405 if (entry.kind == kind or entry.kind == .sym_link) { 406 if (dir.statFile(io.rt(), entry.name, .{})) |stat| { 407 if (stat.kind == kind) { 408 try entries.append(allocator, .{ 409 .path = try allocator.dupe(u8, entry_path), 410 .mtime = stat.mtime.nanoseconds, 411 }); 412 } 413 } else |err| { 414 if (err != error.FileNotFound and err != error.AccessDenied) { 415 io.warn("failed to stat {s}: {}\n", .{ entry.name, err }); 416 } 417 } 418 } 419 420 if (entry.kind == .directory and depth + 1 < max_depth) { 421 var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| { 422 io.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); 423 continue; 424 }; 425 defer sub_dir.close(io.rt()); 426 try collectEntries(allocator, &sub_dir, entry_path, depth + 1, max_depth, kind, ignored_patterns, entries); 427 } 428 } 429 } 430 431 fn runFzfWithWalker(allocator: std.mem.Allocator, db: *hist.Db, kind: std.Io.File.Kind, config: *const cfg.Config, depth: ?usize) !void { 432 const is_dir = kind == .directory; 433 const max_depth = depth orelse config.max_depth; 434 435 var entries = std.ArrayListUnmanaged(Entry).empty; 436 defer { 437 for (entries.items) |e| allocator.free(e.path); 438 entries.deinit(allocator); 439 } 440 441 var mc = cache.Memcached.init(allocator, "127.0.0.1", 11211) catch |err| blk: { 442 if (err != error.ConnectionRefused) { 443 io.warn("failed to initialize memcached: {}\n", .{err}); 444 } 445 break :blk null; 446 }; 447 defer if (mc) |*m| m.deinit(); 448 449 var cache_key: ?[]const u8 = null; 450 defer if (cache_key) |k| allocator.free(k); 451 452 var cache_hit = false; 453 if (mc) |*m| { 454 const cwd = blk: { 455 break :blk std.process.currentPathAlloc(io.rt(), allocator) catch |err| { 456 io.warn("failed to get cwd for cache: {}\n", .{err}); 457 break :blk null; 458 }; 459 }; 460 if (cwd) |c| { 461 defer allocator.free(c); 462 463 const hash = std.hash.Crc32.hash(c); 464 const max_dir_mtime = getMaxDirMtime(config.ignored_patterns, max_depth) catch blk: { 465 break :blk @as(i128, 0); 466 }; 467 const mtime = @divTrunc(max_dir_mtime, std.time.ns_per_s); 468 cache_key = try std.fmt.allocPrint(allocator, "magdalena:look:{s}:{x}:{d}:{d}", .{ if (is_dir) "dir" else "file", hash, mtime, max_depth }); 469 if (cache_key) |key| { 470 const cached_data = try m.get(key); 471 if (cached_data) |data| { 472 defer allocator.free(data); 473 const parsed = try std.json.parseFromSlice([]Entry, allocator, data, .{}); 474 defer parsed.deinit(); 475 for (parsed.value) |e| { 476 try entries.append(allocator, .{ 477 .path = try allocator.dupe(u8, e.path), 478 .mtime = e.mtime, 479 }); 480 } 481 cache_hit = true; 482 } 483 } 484 } 485 } 486 487 if (!cache_hit) { 488 var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true }); 489 defer root.close(io.rt()); 490 try collectEntries(allocator, &root, null, 0, max_depth, kind, config.ignored_patterns, &entries); 491 492 if (mc) |*m| { 493 if (cache_key) |key| { 494 const json_data = try std.json.Stringify.valueAlloc(allocator, entries.items, .{}); 495 defer allocator.free(json_data); 496 try m.set(key, json_data, 3600); 497 } 498 } 499 } 500 501 std.mem.sortUnstable(Entry, entries.items, {}, struct { 502 fn lessThan(_: void, a: Entry, b: Entry) bool { 503 return a.mtime > b.mtime; 504 } 505 }.lessThan); 506 507 var items = std.ArrayListUnmanaged([]const u8).empty; 508 defer items.deinit(allocator); 509 for (entries.items) |entry| { 510 try items.append(allocator, entry.path); 511 } 512 513 const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); 514 defer allocator.free(selected); 515 516 const clean_path = if (std.mem.startsWith(u8, selected, "./")) selected[2..] else selected; 517 518 if (clean_path.len > 0) { 519 std.Io.Dir.cwd().access(io.rt(), clean_path, .{}) catch |err| { 520 io.warn("Selected item no longer exists or is inaccessible: {s} ({})\n", .{ clean_path, err }); 521 return error.UserAbort; 522 }; 523 if (is_dir) { 524 var out_buf: [4096]u8 = undefined; 525 var out = io.getStdout(&out_buf); 526 try out.print("{s}\n", .{clean_path}); 527 try out.end(); 528 } else { 529 try gotoFile(allocator, db, clean_path, null, config); 530 } 531 } 532 } 533 534 const testing = std.testing; 535 536 test "shouldSkip: matches an exact path component" { 537 const ignored = [_][]const u8{ "node_modules", ".git" }; 538 try testing.expect(shouldSkip("src/node_modules/pkg/x.js", &ignored)); 539 try testing.expect(shouldSkip(".git/config", &ignored)); 540 try testing.expect(shouldSkip("a/b/.git", &ignored)); 541 } 542 543 test "shouldSkip: partial component is not a match" { 544 const ignored = [_][]const u8{"node_modules"}; 545 try testing.expect(!shouldSkip("src/lib/main.zig", &ignored)); 546 try testing.expect(!shouldSkip("src/node_modules_old/x", &ignored)); 547 } 548 549 test "shouldSkip: empty pattern set never skips" { 550 try testing.expect(!shouldSkip("a/b/c", &.{})); 551 }