commit 39a38fc7fa002b6abe4dfd4a8b1a94c0af2ec2ab
Author: mtmn <miro@haravara.org>
Date: Sat, 25 Apr 2026 17:10:53 +0200
init
Diffstat:
| A | BUILD | | | 37 | +++++++++++++++++++++++++++++++++++++ |
| A | README.md | | | 4 | ++++ |
| A | build.zig | | | 34 | ++++++++++++++++++++++++++++++++++ |
| A | build.zig.zon | | | 19 | +++++++++++++++++++ |
| A | config.json | | | 51 | +++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension.bzl | | | 75 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | justfile | | | 5 | +++++ |
| A | src/cfg.zig | | | 36 | ++++++++++++++++++++++++++++++++++++ |
| A | src/cli.zig | | | 144 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/db.zig | | | 347 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/fzf.zig | | | 557 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/io.zig | | | 48 | ++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/main.zig | | | 117 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/memcached.zig | | | 230 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | themes/bat/magdalena.tmTheme | | | 87 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
15 files changed, 1791 insertions(+), 0 deletions(-)
diff --git a/BUILD b/BUILD
@@ -0,0 +1,37 @@
+load("@rules_zig//zig:defs.bzl", "zig_binary", "zig_library")
+load("//bazel:local-deploy.bzl", "local_deploy")
+
+package(default_visibility = ["//visibility:public"])
+
+zig_binary(
+ name = "magdalena",
+ main = "src/main.zig",
+ srcs = glob(["src/*.zig"]),
+ zigopts = [
+ "-O",
+ "ReleaseFast",
+ "-Doptimize=ReleaseFast",
+ ],
+ deps = [
+ "@zul//:zul",
+ ],
+)
+
+zig_binary(
+ name = "magdalena_debug",
+ main = "src/main.zig",
+ srcs = glob(["src/*.zig"]),
+ zigopts = [
+ "-O",
+ "Debug",
+ "-Doptimize=Debug",
+ ],
+ deps = [
+ "@zul//:zul",
+ ],
+)
+
+local_deploy(
+ name = "deploy",
+ srcs = [":magdalena"],
+)
diff --git a/README.md b/README.md
@@ -0,0 +1,4 @@
+# magdalena
+Interactive shell navigation and history in zig.
+
+[](https://youtu.be/qwrrpBc7xWA/)
diff --git a/build.zig b/build.zig
@@ -0,0 +1,34 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const zul = b.dependency("zul", .{
+ .target = target,
+ .optimize = optimize,
+ });
+
+ const exe = b.addExecutable(.{
+ .name = "magdalena",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{
+ .{ .name = "zul", .module = zul.module("zul") },
+ },
+ }),
+ });
+ exe.linkLibC();
+
+ b.installArtifact(exe);
+
+ const run_cmd = b.addRunArtifact(exe);
+ run_cmd.step.dependOn(b.getInstallStep());
+ if (b.args) |args| {
+ run_cmd.addArgs(args);
+ }
+ const run_step = b.step("run", "Run the app");
+ run_step.dependOn(&run_cmd.step);
+}
diff --git a/build.zig.zon b/build.zig.zon
@@ -0,0 +1,19 @@
+.{
+ .name = .magdalena,
+ .version = "0.1.4",
+ .fingerprint = 0xd065e535ca3c8b31,
+ .minimum_zig_version = "0.15.2",
+ .dependencies = .{
+ .zul = .{
+ .url = "https://github.com/karlseguin/zul/archive/e770047f208cf4538fcd9fc64550c84fd5492fc4.tar.gz",
+ .hash = "1220c0fe8bdde860b533f5625b72daaf2c0c686ac7eaf72e123aaf6998d785439b93",
+ },
+ },
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ "LICENSE",
+ "README.md",
+ },
+}
diff --git a/config.json b/config.json
@@ -0,0 +1,51 @@
+{
+ "editor": "vim",
+ "max_depth": 5,
+ "ignored_patterns": [
+ ".git",
+ ".jj",
+ ".zig-cache",
+ "node_modules",
+ "target"
+ ],
+ "fzf_opts": [
+ "--highlight-line",
+ "--ansi",
+ "--layout=reverse",
+ "--border=rounded",
+ "--color=bg+:#2a1f2e,bg:-1,spinner:#c084b8,hl:#e08060",
+ "--color=fg:#c8b8bf,header:#c4607a,info:#7a6e7a,pointer:#c084b8",
+ "--color=marker:#e06fad,fg+:#f0dfe5,prompt:#7a9fd4,hl+:#f0a070",
+ "--color=border:#3d2a42"
+ ],
+ "fav_dirs": [
+ "/home/miro/src"
+ ],
+ "openers": [
+ {
+ "extensions": ["mp4", "mkv", "avi", "webm"],
+ "action": "media_player",
+ "command": "mpv"
+ },
+ {
+ "extensions": ["flac", "aiff", "aif", "mp3", "wav"],
+ "action": "audio_player",
+ "command": "mpv"
+ },
+ {
+ "extensions": ["jpg", "jpeg", "png", "webp", "svg"],
+ "action": "image_viewer",
+ "command": "nsxiv-rifle"
+ },
+ {
+ "extensions": ["pdf", "epub", "mobi"],
+ "action": "document_reader",
+ "command": "sioyek"
+ },
+ {
+ "extensions": ["html"],
+ "action": "web_browser",
+ "command": "firefox"
+ }
+ ]
+}
diff --git a/extension.bzl b/extension.bzl
@@ -0,0 +1,75 @@
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+def _magdalena_deps_impl(_ctx):
+ http_archive(
+ name = "zul",
+ sha256 = "1dee366aaab6257e9d2b7d8b54dfb470bc2c0404be5975d76365b8ef6fd51ab7",
+ strip_prefix = "zul-e770047f208cf4538fcd9fc64550c84fd5492fc4",
+ url = "https://github.com/karlseguin/zul/archive/e770047f208cf4538fcd9fc64550c84fd5492fc4.tar.gz",
+ build_file_content = """
+load("@rules_zig//zig:defs.bzl", "zig_library")
+zig_library(
+ name = "zul",
+ main = "src/zul.zig",
+ srcs = glob(["src/**/*.zig"]),
+ visibility = ["//visibility:public"],
+)
+""",
+ )
+
+ http_archive(
+ name = "zio",
+ sha256 = "bcda5af0639c1392f55f909536759bb0df254c9966d1f9bdd9f828212c221816",
+ strip_prefix = "zio-3ac234eae165177cd75742f922851dc5d373fd12",
+ url = "https://github.com/lalinsky/zio/archive/3ac234eae165177cd75742f922851dc5d373fd12.tar.gz",
+ build_file_content = """
+load("@rules_zig//zig:defs.bzl", "zig_library")
+
+genrule(
+ name = "zio_options_gen",
+ outs = ["zio_options.zig"],
+ cmd = 'echo \\'pub const backend: ?[]const u8 = "epoll";\\' > $@',
+)
+
+zig_library(
+ name = "zio_options",
+ main = "zio_options.zig",
+ visibility = ["//visibility:public"],
+)
+
+zig_library(
+ name = "zio",
+ main = "src/zio.zig",
+ srcs = glob(["src/**/*.zig"]),
+ visibility = ["//visibility:public"],
+ deps = [
+ ":zio_options",
+ "@rules_zig//zig/lib:libc",
+ ],
+)
+""",
+ )
+
+ http_archive(
+ name = "memcached",
+ sha256 = "702822a8d4c41b8549c49f42df48e703e279644f3a5e523b7e221b7c57b1c175",
+ strip_prefix = "memcached.zig-d4e93b1663074db714ec4d6ade513d023ab92ee4",
+ url = "https://github.com/lalinsky/memcached.zig/archive/d4e93b1663074db714ec4d6ade513d023ab92ee4.tar.gz",
+ build_file_content = """
+load("@rules_zig//zig:defs.bzl", "zig_library")
+zig_library(
+ name = "memcached",
+ main = "src/root.zig",
+ srcs = glob(["src/**/*.zig"]),
+ visibility = ["//visibility:public"],
+ deps = [
+ "@zio//:zio",
+ "@rules_zig//zig/lib:libc",
+ ],
+)
+""",
+ )
+
+magdalena_deps = module_extension(
+ implementation = _magdalena_deps_impl,
+)
diff --git a/justfile b/justfile
@@ -0,0 +1,5 @@
+run opt="Debug":
+ zig build run -Doptimize={{opt}}
+
+build opt="Debug":
+ zig build -Doptimize={{opt}} -p build/{{opt}}
diff --git a/src/cfg.zig b/src/cfg.zig
@@ -0,0 +1,36 @@
+const std = @import("std");
+
+pub const Config = struct {
+ openers: []Opener = &.{},
+ ignored_patterns: []const []const u8 = &.{},
+ fzf_opts: []const []const u8 = &.{},
+ fav_dirs: []const []const u8 = &.{},
+ editor: ?[]const u8 = null,
+ max_depth: usize = 3,
+
+ pub const Opener = struct {
+ extensions: []const []const u8,
+ action: []const u8,
+ command: []const u8,
+ };
+};
+
+pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) {
+ const home = std.posix.getenv("HOME") orelse return null;
+ const path = std.fs.path.join(allocator, &.{ home, ".config", "magdalena", "config.json" }) catch return null;
+ defer allocator.free(path);
+
+ const file = std.fs.cwd().openFile(path, .{}) catch return null;
+ defer file.close();
+
+ const content = file.readToEndAlloc(allocator, std.math.maxInt(usize)) catch return null;
+ defer allocator.free(content);
+
+ return std.json.parseFromSlice(Config, allocator, content, .{
+ .ignore_unknown_fields = true,
+ .allocate = .alloc_always,
+ }) catch |err| {
+ std.log.err("failed to parse config: {any}", .{err});
+ return null;
+ };
+}
diff --git a/src/cli.zig b/src/cli.zig
@@ -0,0 +1,144 @@
+const std = @import("std");
+const zul = @import("zul");
+const io = @import("io.zig");
+
+pub const Action = enum {
+ recent_dirs,
+ recent_files,
+ fav_dirs,
+ search,
+ goto_dir,
+ goto_file,
+ look_file,
+ look_dir,
+ grep,
+ cleanup,
+ log_dir,
+ log_file,
+ help,
+};
+
+pub const Args = struct {
+ action: Action,
+ query: ?[]const u8 = null,
+ log_path: ?[]const u8 = null,
+ file_type: ?[]const u8 = null,
+ file_action: ?[]const u8 = null,
+ depth: ?usize = null,
+
+ _cla: zul.CommandLineArgs,
+
+ pub fn deinit(self: Args) void {
+ self._cla.deinit();
+ }
+};
+
+pub fn parseArgs(allocator: std.mem.Allocator) !Args {
+ const cla = try zul.CommandLineArgs.parse(allocator);
+
+ var res = Args{
+ .action = .help,
+ ._cla = cla,
+ };
+
+ if (cla.contains("help") or cla.contains("-h") or cla.contains("--help")) {
+ res.action = .help;
+ return res;
+ }
+
+ if (cla.contains("clean") or cla.contains("c")) {
+ res.action = .cleanup;
+ return res;
+ }
+
+ if (cla.get("depth")) |d| {
+ res.depth = try std.fmt.parseInt(usize, d, 10);
+ } else if (cla.get("d")) |d| {
+ res.depth = try std.fmt.parseInt(usize, d, 10);
+ }
+
+ if (res.depth == null) {
+ for (cla.tail, 0..) |arg, i| {
+ if (std.mem.startsWith(u8, arg, "--depth=")) {
+ res.depth = try std.fmt.parseInt(usize, arg["--depth=".len..], 10);
+ } else if (std.mem.eql(u8, arg, "--depth") or std.mem.eql(u8, arg, "-d")) {
+ if (i + 1 < cla.tail.len) {
+ res.depth = try std.fmt.parseInt(usize, cla.tail[i + 1], 10);
+ }
+ }
+ if (res.depth != null) break;
+ }
+ }
+
+ if (cla.tail.len == 0) {
+ return res;
+ }
+
+ const cmd_str = cla.tail[0];
+ if (std.mem.eql(u8, cmd_str, "recent-dirs")) {
+ res.action = .recent_dirs;
+ } else if (std.mem.eql(u8, cmd_str, "recent-files")) {
+ res.action = .recent_files;
+ } else if (std.mem.eql(u8, cmd_str, "fav-dirs")) {
+ res.action = .fav_dirs;
+ } else if (std.mem.eql(u8, cmd_str, "search")) {
+ res.action = .search;
+ } else if (std.mem.eql(u8, cmd_str, "goto-dir")) {
+ res.action = .goto_dir;
+ } else if (std.mem.eql(u8, cmd_str, "goto-file")) {
+ res.action = .goto_file;
+ } else if (std.mem.eql(u8, cmd_str, "look-file")) {
+ res.action = .look_file;
+ } else if (std.mem.eql(u8, cmd_str, "look-dir")) {
+ res.action = .look_dir;
+ } else if (std.mem.eql(u8, cmd_str, "grep")) {
+ res.action = .grep;
+ } else if (std.mem.eql(u8, cmd_str, "log-dir")) {
+ res.action = .log_dir;
+ } else if (std.mem.eql(u8, cmd_str, "log-file")) {
+ res.action = .log_file;
+ } else {
+ res.action = .help;
+ return res;
+ }
+
+ if (cla.tail.len > 1) {
+ const arg = cla.tail[1];
+ if (res.action == .search) {
+ res.query = arg;
+ } else if (res.action == .log_dir) {
+ res.log_path = arg;
+ } else if (res.action == .log_file) {
+ res.log_path = arg;
+ if (cla.tail.len > 2) res.file_type = cla.tail[2];
+ if (cla.tail.len > 3) res.file_action = cla.tail[3];
+ }
+ }
+
+ return res;
+}
+
+pub fn printUsage() !void {
+ var buf: [4096]u8 = undefined;
+ var writer = io.getStdout(&buf);
+ try writer.print(
+ \\Usage:
+ \\ magdalena recent-dirs
+ \\ magdalena recent-files
+ \\ magdalena fav-dirs
+ \\ magdalena goto-dir
+ \\ magdalena goto-file
+ \\ magdalena log-dir <path>
+ \\ magdalena log-file <path> [type] [action]
+ \\ magdalena search <query>
+ \\ magdalena look-file [--depth <n>]
+ \\ magdalena look-dir [--depth <n>]
+ \\ magdalena grep
+ \\
+ \\Options:
+ \\ -c, --clean Perform cleanup
+ \\ -h, --help Show this help
+ \\
+ , .{});
+ try writer.end();
+}
diff --git a/src/db.zig b/src/db.zig
@@ -0,0 +1,347 @@
+const std = @import("std");
+const zul = @import("zul");
+const io = @import("io.zig");
+
+pub const DirectoryEntry = struct {
+ path: []const u8,
+ timestamp: ?[]const u8 = null,
+};
+
+pub const FileEntry = struct {
+ path: []const u8,
+ file_type: []const u8,
+ action: []const u8,
+ timestamp: ?[]const u8 = null,
+};
+
+pub const SearchResult = struct {
+ directories: []DirectoryEntry,
+ files: []FileEntry,
+};
+
+pub const Db = struct {
+ allocator: std.mem.Allocator,
+ base_path: []const u8,
+ dirs_path: []const u8,
+ files_path: []const u8,
+
+ pub fn init(allocator: std.mem.Allocator, path: []const u8) !Db {
+ const base_path = try allocator.dupe(u8, path);
+ errdefer allocator.free(base_path);
+
+ const dirs_path = try std.fs.path.join(allocator, &.{ base_path, "dirs.log" });
+ errdefer allocator.free(dirs_path);
+
+ const files_path = try std.fs.path.join(allocator, &.{ base_path, "files.log" });
+ errdefer allocator.free(files_path);
+
+ const res = Db{
+ .allocator = allocator,
+ .base_path = base_path,
+ .dirs_path = dirs_path,
+ .files_path = files_path,
+ };
+
+ // Ensure directory exists
+ std.fs.cwd().makePath(base_path) catch |err| {
+ if (err != error.PathAlreadyExists) return err;
+ };
+
+ // Ensure files exist
+ for (&[_][]const u8{ dirs_path, files_path }) |p| {
+ var f = std.fs.cwd().openFile(p, .{ .mode = .read_write }) catch |err| {
+ if (err == error.FileNotFound) {
+ _ = try std.fs.cwd().createFile(p, .{ .truncate = false });
+ continue;
+ }
+ return err;
+ };
+ f.close();
+ }
+
+ return res;
+ }
+
+ pub fn deinit(self: *Db) void {
+ self.allocator.free(self.base_path);
+ self.allocator.free(self.dirs_path);
+ self.allocator.free(self.files_path);
+ }
+
+ pub fn recentDirs(self: *Db) !zul.Managed([]DirectoryEntry) {
+ const file = std.fs.cwd().openFile(self.dirs_path, .{}) catch |err| {
+ if (err == error.FileNotFound) return zul.Managed([]DirectoryEntry).fromJson(try std.json.parseFromSlice([]DirectoryEntry, self.allocator, "[]", .{}));
+ return err;
+ };
+ defer file.close();
+
+ const content = try file.readToEndAlloc(self.allocator, std.math.maxInt(usize));
+ errdefer self.allocator.free(content);
+
+ var arena = try self.allocator.create(std.heap.ArenaAllocator);
+ errdefer {
+ arena.deinit();
+ self.allocator.destroy(arena);
+ }
+ arena.* = std.heap.ArenaAllocator.init(self.allocator);
+ const allocator = arena.allocator();
+
+ var list = std.ArrayListUnmanaged(DirectoryEntry){};
+
+ var seen = std.StringHashMap(void).init(allocator);
+
+ // Use splitBackwardsScalar to read the newest entries first
+ var it = std.mem.splitBackwardsScalar(u8, content, '\n');
+ while (it.next()) |line| {
+ const trimmed = std.mem.trim(u8, line, " \r\t");
+ if (trimmed.len == 0) continue;
+
+ var line_it = std.mem.splitScalar(u8, trimmed, '|');
+ const ts = line_it.next() orelse continue;
+ const dir_path = line_it.next() orelse continue;
+
+ if (seen.contains(dir_path)) continue;
+ try seen.put(dir_path, {});
+
+ try list.append(allocator, .{
+ .path = try allocator.dupe(u8, dir_path),
+ .timestamp = try allocator.dupe(u8, ts),
+ });
+ }
+
+ self.allocator.free(content);
+ return .{
+ .arena = arena,
+ .value = try list.toOwnedSlice(allocator),
+ };
+ }
+
+ pub fn recentFiles(self: *Db) !zul.Managed([]FileEntry) {
+ const file = std.fs.cwd().openFile(self.files_path, .{}) catch |err| {
+ if (err == error.FileNotFound) return zul.Managed([]FileEntry).fromJson(try std.json.parseFromSlice([]FileEntry, self.allocator, "[]", .{}));
+ return err;
+ };
+ defer file.close();
+
+ const content = try file.readToEndAlloc(self.allocator, std.math.maxInt(usize));
+ errdefer self.allocator.free(content);
+
+ var arena = try self.allocator.create(std.heap.ArenaAllocator);
+ errdefer {
+ arena.deinit();
+ self.allocator.destroy(arena);
+ }
+ arena.* = std.heap.ArenaAllocator.init(self.allocator);
+ const allocator = arena.allocator();
+
+ var list = std.ArrayListUnmanaged(FileEntry){};
+
+ var seen = std.StringHashMap(void).init(allocator);
+
+ var it = std.mem.splitBackwardsScalar(u8, content, '\n');
+ while (it.next()) |line| {
+ const trimmed = std.mem.trim(u8, line, " \r\t");
+ if (trimmed.len == 0) continue;
+
+ var line_it = std.mem.splitScalar(u8, trimmed, '|');
+ const ts = line_it.next() orelse continue;
+ const ftype = line_it.next() orelse continue;
+ const action = line_it.next() orelse continue;
+ const fpath = line_it.next() orelse continue;
+
+ if (seen.contains(fpath)) continue;
+ try seen.put(fpath, {});
+
+ try list.append(allocator, .{
+ .path = try allocator.dupe(u8, fpath),
+ .file_type = try allocator.dupe(u8, ftype),
+ .action = try allocator.dupe(u8, action),
+ .timestamp = try allocator.dupe(u8, ts),
+ });
+ }
+
+ self.allocator.free(content);
+ return .{
+ .arena = arena,
+ .value = try list.toOwnedSlice(allocator),
+ };
+ }
+
+ pub fn searchHistory(self: *Db, query_str: []const u8) !zul.Managed(SearchResult) {
+ const managed_dirs = try self.recentDirs();
+ defer managed_dirs.deinit();
+ const managed_files = try self.recentFiles();
+ defer managed_files.deinit();
+
+ var arena = try self.allocator.create(std.heap.ArenaAllocator);
+ errdefer {
+ arena.deinit();
+ self.allocator.destroy(arena);
+ }
+ arena.* = std.heap.ArenaAllocator.init(self.allocator);
+ const allocator = arena.allocator();
+
+ var dir_list = std.ArrayListUnmanaged(DirectoryEntry){};
+ for (managed_dirs.value) |d| {
+ if (std.mem.indexOf(u8, d.path, query_str) != null) {
+ try dir_list.append(allocator, .{
+ .path = try allocator.dupe(u8, d.path),
+ .timestamp = if (d.timestamp) |ts| try allocator.dupe(u8, ts) else null,
+ });
+ }
+ }
+
+ var file_list = std.ArrayListUnmanaged(FileEntry){};
+ for (managed_files.value) |f| {
+ if (std.mem.indexOf(u8, f.path, query_str) != null) {
+ try file_list.append(allocator, .{
+ .path = try allocator.dupe(u8, f.path),
+ .file_type = try allocator.dupe(u8, f.file_type),
+ .action = try allocator.dupe(u8, f.action),
+ .timestamp = if (f.timestamp) |ts| try allocator.dupe(u8, ts) else null,
+ });
+ }
+ }
+
+ return .{
+ .arena = arena,
+ .value = .{
+ .directories = try dir_list.toOwnedSlice(allocator),
+ .files = try file_list.toOwnedSlice(allocator),
+ },
+ };
+ }
+
+ fn resolvePath(self: *Db, path: []const u8) ![]const u8 {
+ return std.fs.cwd().realpathAlloc(self.allocator, path) catch {
+ return try std.fs.path.resolve(self.allocator, &[_][]const u8{path});
+ };
+ }
+
+ pub fn logDir(self: *Db, dir_path: []const u8) !void {
+ const abs_path = try self.resolvePath(dir_path);
+ defer self.allocator.free(abs_path);
+
+ const file = std.fs.cwd().openFile(self.dirs_path, .{ .mode = .write_only }) catch |err| {
+ if (err == error.FileNotFound) {
+ _ = try std.fs.cwd().createFile(self.dirs_path, .{ .truncate = false });
+ return try self.logDir(dir_path);
+ }
+ return err;
+ };
+ defer file.close();
+ try file.seekFromEnd(0);
+
+ const now = zul.DateTime.now();
+ var ts_buf: [64]u8 = undefined;
+ var ts_writer = std.io.Writer.fixed(&ts_buf);
+ try now.format(&ts_writer);
+ const ts = ts_buf[0..ts_writer.end];
+
+ var line_buf: [4096]u8 = undefined;
+ const line = try std.fmt.bufPrint(&line_buf, "{s}|{s}\n", .{ ts, abs_path });
+ try file.writeAll(line);
+ }
+
+ pub fn logFile(self: *Db, file_path: []const u8, file_type: []const u8, action: []const u8) !void {
+ const abs_path = try self.resolvePath(file_path);
+ defer self.allocator.free(abs_path);
+
+ const file = std.fs.cwd().openFile(self.files_path, .{ .mode = .write_only }) catch |err| {
+ if (err == error.FileNotFound) {
+ _ = try std.fs.cwd().createFile(self.files_path, .{ .truncate = false });
+ return try self.logFile(file_path, file_type, action);
+ }
+ return err;
+ };
+ defer file.close();
+ try file.seekFromEnd(0);
+
+ const now = zul.DateTime.now();
+ var ts_buf: [64]u8 = undefined;
+ var ts_writer = std.io.Writer.fixed(&ts_buf);
+ try now.format(&ts_writer);
+ const ts = ts_buf[0..ts_writer.end];
+
+ var line_buf: [4096]u8 = undefined;
+ const line = try std.fmt.bufPrint(&line_buf, "{s}|{s}|{s}|{s}\n", .{ ts, file_type, action, abs_path });
+ try file.writeAll(line);
+ }
+
+ pub fn cleanup(self: *Db) !void {
+ try self.cleanupFile(self.dirs_path, 1);
+ try self.cleanupFile(self.files_path, 3);
+ }
+
+ fn cleanupFile(self: *Db, path: []const u8, path_col_idx: usize) !void {
+ io.warn("Cleaning up {s}...\n", .{path});
+
+ const file = try std.fs.cwd().openFile(path, .{});
+ const content = try file.readToEndAlloc(self.allocator, std.math.maxInt(usize));
+ file.close();
+ defer self.allocator.free(content);
+
+ var seen = std.StringHashMap(void).init(self.allocator);
+ defer seen.deinit();
+
+ var lines = std.ArrayListUnmanaged([]const u8){};
+ defer lines.deinit(self.allocator);
+
+ var total_entries: usize = 0;
+ var missing_entries: usize = 0;
+ var it = std.mem.splitBackwardsScalar(u8, content, '\n');
+ while (it.next()) |line| {
+ const trimmed = std.mem.trim(u8, line, " \r\t");
+ if (trimmed.len == 0) continue;
+ total_entries += 1;
+
+ var line_it = std.mem.splitScalar(u8, trimmed, '|');
+ var item_path: ?[]const u8 = null;
+ var i: usize = 0;
+ while (line_it.next()) |col| : (i += 1) {
+ if (i == path_col_idx) {
+ item_path = col;
+ break;
+ }
+ }
+
+ const p = item_path orelse continue;
+
+ if (seen.contains(p)) continue;
+
+ std.fs.cwd().access(p, .{}) catch |err| {
+ if (err == error.FileNotFound) {
+ missing_entries += 1;
+ continue;
+ }
+ return err;
+ };
+
+ try seen.put(p, {});
+ try lines.append(self.allocator, trimmed);
+ }
+
+ const duplicates = total_entries - lines.items.len - missing_entries;
+ io.warn(" Entries: {d} total, {d} unique, {d} missing, {d} duplicates removed\n", .{ total_entries, lines.items.len, missing_entries, duplicates });
+
+ const write_file = try std.fs.cwd().createFile(path, .{ .truncate = true });
+ defer write_file.close();
+
+ var write_buf: [4096]u8 = undefined;
+ var writer = write_file.writer(&write_buf);
+
+ var i: usize = lines.items.len;
+ while (i > 0) {
+ i -= 1;
+ try writer.interface.print("{s}\n", .{lines.items[i]});
+ }
+ try writer.end();
+ }
+};
+
+pub fn getDefaultDbPath(allocator: std.mem.Allocator) ![]const u8 {
+ if (std.posix.getenv("HOME")) |home| {
+ return std.fs.path.join(allocator, &.{ home, ".magdalena" });
+ }
+ return error.HomeNotFound;
+}
diff --git a/src/fzf.zig b/src/fzf.zig
@@ -0,0 +1,557 @@
+const std = @import("std");
+
+const cache = @import("memcached.zig");
+const cfg = @import("cfg.zig");
+const hist = @import("db.zig");
+const io = @import("io.zig");
+
+fn gotoFile(allocator: std.mem.Allocator, db: ?*hist.Db, file_path: []const u8, line: ?[]const u8, config: *const cfg.Config) !void {
+ const ext = if (std.mem.lastIndexOfScalar(u8, file_path, '.')) |idx| file_path[idx + 1 ..] else "";
+
+ var action: []const u8 = "text_editor";
+ var command: ?[]const u8 = null;
+
+ for (config.openers) |opener| {
+ for (opener.extensions) |e| {
+ if (std.mem.eql(u8, e, ext)) {
+ action = opener.action;
+ command = opener.command;
+ break;
+ }
+ }
+ if (command != null) break;
+ }
+
+ const term = if (command) |cmd| blk: {
+ var child = std.process.Child.init(&.{ cmd, file_path }, allocator);
+ child.stdin_behavior = .Inherit;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+ break :blk try child.spawnAndWait();
+ } else blk: {
+ const editor = config.editor orelse
+ std.process.getEnvVarOwned(allocator, "EDITOR") catch |err| switch (err) {
+ error.EnvironmentVariableNotFound => return error.EditorNotSet,
+ else => return err,
+ };
+ defer if (config.editor == null) allocator.free(editor);
+
+ if (line) |l| {
+ const line_arg = try std.fmt.allocPrint(allocator, "+{s}", .{l});
+ defer allocator.free(line_arg);
+ var child = std.process.Child.init(&.{ editor, line_arg, file_path }, allocator);
+ child.stdin_behavior = .Inherit;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+ break :blk try child.spawnAndWait();
+ }
+
+ var child = std.process.Child.init(&.{ editor, file_path }, allocator);
+ child.stdin_behavior = .Inherit;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+ break :blk try child.spawnAndWait();
+ };
+
+ if (term == .Exited and term.Exited == 0) {
+ if (db) |d| {
+ try d.logFile(file_path, ext, action);
+ }
+ }
+}
+
+const Fzf = struct {
+ child: std.process.Child,
+ argv: [][]const u8,
+ allocator: std.mem.Allocator,
+
+ pub fn init(allocator: std.mem.Allocator, fzf_opts: []const []const u8, extra_opts: []const []const u8) !Fzf {
+ var argv = try allocator.alloc([]const u8, 1 + fzf_opts.len + extra_opts.len);
+ errdefer allocator.free(argv);
+
+ var i: usize = 0;
+ argv[i] = "fzf";
+ i += 1;
+ for (fzf_opts) |opt| {
+ argv[i] = opt;
+ i += 1;
+ }
+ for (extra_opts) |opt| {
+ argv[i] = opt;
+ i += 1;
+ }
+
+ var child = std.process.Child.init(argv, allocator);
+ child.stdin_behavior = .Pipe;
+ child.stdout_behavior = .Pipe;
+ child.stderr_behavior = .Inherit;
+
+ return .{
+ .child = child,
+ .argv = argv,
+ .allocator = allocator,
+ };
+ }
+
+ pub fn deinit(self: *Fzf) void {
+ self.allocator.free(self.argv);
+ }
+};
+
+fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool {
+ var it = std.mem.splitScalar(u8, path, std.fs.path.sep);
+ while (it.next()) |component| {
+ for (ignored_patterns) |pattern| {
+ if (std.mem.eql(u8, component, pattern)) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, items: []const []const u8) ![]u8 {
+ var fzf = try Fzf.init(allocator, fzf_opts, &[_][]const u8{});
+ defer fzf.deinit();
+ const child = &fzf.child;
+ try child.spawn();
+
+ if (child.stdin) |*stdin| {
+ // Write items in batches to prevent excessive buffer usage
+ const batch_size = 1000;
+ for (0..((items.len + batch_size - 1) / batch_size)) |batch_idx| {
+ const start = batch_idx * batch_size;
+ const end = @min(start + batch_size, items.len);
+
+ for (start..end) |i| {
+ stdin.writeAll(items[i]) catch |err| {
+ if (err == error.BrokenPipe) break;
+ return err;
+ };
+ stdin.writeAll("\n") catch |err| {
+ if (err == error.BrokenPipe) break;
+ return err;
+ };
+ }
+ }
+ stdin.close();
+ child.stdin = null;
+ }
+
+ var stdout_buf = std.ArrayListUnmanaged(u8){};
+ defer stdout_buf.deinit(allocator);
+
+ if (child.stdout) |stdout| {
+ var buffer: [1024]u8 = undefined;
+ while (true) {
+ const bytes_read = try stdout.read(&buffer);
+ if (bytes_read == 0) break;
+ try stdout_buf.appendSlice(allocator, buffer[0..bytes_read]);
+ }
+ }
+
+ const term = try child.wait();
+ if (term != .Exited or term.Exited != 0) return error.UserAbort;
+ const selected = std.mem.trim(u8, stdout_buf.items, " \n\r\t");
+ if (selected.len == 0) return error.UserAbort;
+
+ return allocator.dupe(u8, selected);
+}
+
+pub fn recentDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
+ const managed_dirs = try db.recentDirs();
+ defer managed_dirs.deinit();
+ const dirs = managed_dirs.value;
+
+ if (dirs.len == 0) {
+ io.warn("No recent directories found.\n", .{});
+ return;
+ }
+
+ var items = std.ArrayListUnmanaged([]const u8){};
+ defer items.deinit(allocator);
+
+ for (dirs) |dir| {
+ if (shouldSkip(dir.path, config.ignored_patterns)) continue;
+ try items.append(allocator, dir.path);
+ }
+
+ const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
+ defer allocator.free(selected);
+
+ std.fs.accessAbsolute(selected, .{}) catch |err| {
+ io.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err });
+ return error.UserAbort;
+ };
+
+ var out_buf: [4096]u8 = undefined;
+ var out = io.getStdout(&out_buf);
+ try out.print("{s}\n", .{selected});
+ try out.end();
+}
+
+pub fn favDirs(allocator: std.mem.Allocator, config: *const cfg.Config) !void {
+ if (config.fav_dirs.len == 0) {
+ io.warn("No favorite directories found in config.\n", .{});
+ return;
+ }
+
+ var items = std.ArrayListUnmanaged([]const u8){};
+ defer {
+ for (items.items) |item| allocator.free(item);
+ items.deinit(allocator);
+ }
+
+ for (config.fav_dirs) |dir| {
+ const expanded = try expandPath(allocator, dir);
+ try items.append(allocator, expanded);
+ }
+
+ const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
+ defer allocator.free(selected);
+
+ var out_buf: [4096]u8 = undefined;
+ var out = io.getStdout(&out_buf);
+ try out.print("{s}\n", .{selected});
+ try out.end();
+}
+
+fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
+ var res = std.ArrayListUnmanaged(u8){};
+ errdefer res.deinit(allocator);
+
+ var i: usize = 0;
+ while (i < path.len) {
+ if (std.mem.startsWith(u8, path[i..], "$HOME")) {
+ const home = std.posix.getenv("HOME") orelse "/home/user";
+ try res.appendSlice(allocator, home);
+ i += 5;
+ } else {
+ try res.append(allocator, path[i]);
+ i += 1;
+ }
+ }
+
+ return res.toOwnedSlice(allocator);
+}
+
+pub fn recentFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
+ const managed_files = try db.recentFiles();
+ defer managed_files.deinit();
+ const files = managed_files.value;
+
+ if (files.len == 0) {
+ io.warn("No recent files found.\n", .{});
+ return;
+ }
+
+ var items = std.ArrayListUnmanaged([]const u8){};
+ defer items.deinit(allocator);
+
+ for (files) |file| {
+ if (shouldSkip(file.path, config.ignored_patterns)) continue;
+ try items.append(allocator, file.path);
+ }
+
+ const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
+ defer allocator.free(selected);
+
+ try std.fs.accessAbsolute(selected, .{});
+
+ try gotoFile(allocator, db, selected, null, config);
+}
+
+pub fn grep(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
+ const initial_query = "";
+
+ var rg_cmd = std.ArrayListUnmanaged(u8){};
+ defer rg_cmd.deinit(allocator);
+ try rg_cmd.appendSlice(allocator, "change:reload:rg --column --line-number --no-heading --color=always --smart-case");
+ for (config.ignored_patterns) |pattern| {
+ try rg_cmd.appendSlice(allocator, " --glob '!");
+ try rg_cmd.appendSlice(allocator, pattern);
+ try rg_cmd.appendSlice(allocator, "/*'");
+ try rg_cmd.appendSlice(allocator, " --glob '!");
+ try rg_cmd.appendSlice(allocator, pattern);
+ try rg_cmd.appendSlice(allocator, "'");
+ }
+ try rg_cmd.appendSlice(allocator, " {q} || true");
+
+ var fzf = try Fzf.init(allocator, config.fzf_opts, &[_][]const u8{
+ "--disabled",
+ "--query",
+ initial_query,
+ "--bind",
+ rg_cmd.items,
+ "--delimiter",
+ ":",
+ "--preview",
+ "bat --highlight-line {2} {1}",
+ "--preview-window",
+ "right,60%,border-left,+{2}+3/3,~3",
+ });
+ defer fzf.deinit();
+ const child = &fzf.child;
+
+ child.stdin_behavior = .Inherit;
+ child.stdout_behavior = .Pipe;
+ child.stderr_behavior = .Inherit;
+
+ try child.spawn();
+
+ var stdout_buf = std.ArrayListUnmanaged(u8){};
+ defer stdout_buf.deinit(allocator);
+
+ if (child.stdout) |stdout| {
+ var buffer: [1024]u8 = undefined;
+ while (true) {
+ const bytes_read = try stdout.read(&buffer);
+ if (bytes_read == 0) break;
+ try stdout_buf.appendSlice(allocator, buffer[0..bytes_read]);
+ }
+ }
+
+ const term = try child.wait();
+ if (term != .Exited or term.Exited != 0) return error.UserAbort;
+ const selected = std.mem.trim(u8, stdout_buf.items, " \n\r\t");
+ if (selected.len == 0) return error.UserAbort;
+
+ var it = std.mem.splitScalar(u8, selected, ':');
+ if (it.next()) |file| {
+ const line = it.next();
+ try gotoFile(allocator, db, file, line, config);
+ }
+}
+
+pub fn lookFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void {
+ try runFzfWithWalker(allocator, db, .file, config, depth);
+}
+
+pub fn lookDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void {
+ try runFzfWithWalker(allocator, db, .directory, config, depth);
+}
+
+const Entry = struct {
+ path: []const u8,
+ mtime: i128,
+};
+
+fn getMaxDirMtime(allocator: std.mem.Allocator, ignored_patterns: []const []const u8, max_depth: usize) !i128 {
+ var max_mtime: i128 = 0;
+
+ if (std.fs.cwd().statFile(".")) |stat| {
+ max_mtime = stat.mtime;
+ } else |_| {}
+
+ const StackEntry = struct {
+ dir: std.fs.Dir,
+ depth: usize,
+ };
+ var stack = std.ArrayListUnmanaged(StackEntry){};
+ defer {
+ for (stack.items) |*se| se.dir.close();
+ stack.deinit(allocator);
+ }
+
+ try stack.append(allocator, .{ .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), .depth = 0 });
+
+ while (stack.items.len > 0) {
+ var current = stack.pop() orelse unreachable;
+ defer current.dir.close();
+
+ var it = current.dir.iterate();
+ while (try it.next()) |entry| {
+ var skip = false;
+ for (ignored_patterns) |pattern| {
+ if (std.mem.eql(u8, entry.name, pattern)) {
+ skip = true;
+ break;
+ }
+ }
+ if (skip) continue;
+
+ const is_dir = entry.kind == .directory;
+ if (is_dir or entry.kind == .sym_link) {
+ const stat = current.dir.statFile(entry.name) catch |err| {
+ if (err != error.FileNotFound and err != error.AccessDenied) {
+ io.warn("failed to stat {s}: {}\n", .{ entry.name, err });
+ }
+ continue;
+ };
+ if (stat.kind == .directory) {
+ if (stat.mtime > max_mtime) max_mtime = stat.mtime;
+
+ if (is_dir and current.depth + 1 < max_depth) {
+ const sub_dir = current.dir.openDir(entry.name, .{ .iterate = true }) catch |err| {
+ io.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
+ continue;
+ };
+ try stack.append(allocator, .{ .dir = sub_dir, .depth = current.depth + 1 });
+ }
+ }
+ }
+ }
+ }
+
+ return max_mtime;
+}
+
+fn runFzfWithWalker(allocator: std.mem.Allocator, db: *hist.Db, kind: std.fs.File.Kind, config: *const cfg.Config, depth: ?usize) !void {
+ const is_dir = kind == .directory;
+ const max_depth = depth orelse config.max_depth;
+
+ var entries = std.ArrayListUnmanaged(Entry){};
+ defer {
+ for (entries.items) |e| allocator.free(e.path);
+ entries.deinit(allocator);
+ }
+
+ var mc = cache.Memcached.init(allocator, "127.0.0.1", 11211) catch |err| blk: {
+ if (err != error.ConnectionRefused) {
+ io.warn("failed to initialize memcached: {}\n", .{err});
+ }
+ break :blk null;
+ };
+ defer if (mc) |*m| m.deinit();
+
+ var cache_key: ?[]const u8 = null;
+ defer if (cache_key) |k| allocator.free(k);
+
+ var cache_hit = false;
+ if (mc) |*m| {
+ const cwd = blk: {
+ break :blk std.process.getCwdAlloc(allocator) catch |err| {
+ io.warn("failed to get cwd for cache: {}\n", .{err});
+ break :blk null;
+ };
+ };
+ if (cwd) |c| {
+ defer allocator.free(c);
+
+ const hash = std.hash.Crc32.hash(c);
+ const max_dir_mtime = getMaxDirMtime(allocator, config.ignored_patterns, max_depth) catch blk: {
+ break :blk @as(i128, 0);
+ };
+ const mtime = @divTrunc(max_dir_mtime, std.time.ns_per_s);
+ cache_key = try std.fmt.allocPrint(allocator, "magdalena:look:{s}:{x}:{d}:{d}", .{ if (is_dir) "dir" else "file", hash, mtime, max_depth });
+ if (cache_key) |key| {
+ const cached_data = try m.get(key);
+ if (cached_data) |data| {
+ defer allocator.free(data);
+ const parsed = try std.json.parseFromSlice([]Entry, allocator, data, .{});
+ defer parsed.deinit();
+ for (parsed.value) |e| {
+ try entries.append(allocator, .{
+ .path = try allocator.dupe(u8, e.path),
+ .mtime = e.mtime,
+ });
+ }
+ cache_hit = true;
+ }
+ }
+ }
+ }
+
+ if (!cache_hit) {
+ const StackEntry = struct {
+ dir: std.fs.Dir,
+ path: ?[]const u8,
+ depth: usize,
+ };
+ var stack = std.ArrayListUnmanaged(StackEntry){};
+ defer {
+ for (stack.items) |*se| se.dir.close();
+ stack.deinit(allocator);
+ }
+
+ try stack.append(allocator, .{ .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), .path = null, .depth = 0 });
+
+ while (stack.items.len > 0) {
+ var current = stack.pop() orelse unreachable;
+ defer {
+ current.dir.close();
+ if (current.path) |p| allocator.free(p);
+ }
+
+ var it = current.dir.iterate();
+ while (try it.next()) |entry| {
+ const entry_path = if (current.path) |p|
+ try std.fs.path.join(allocator, &.{ p, entry.name })
+ else
+ try allocator.dupe(u8, entry.name);
+ errdefer allocator.free(entry_path);
+
+ if (shouldSkip(entry_path, config.ignored_patterns)) {
+ allocator.free(entry_path);
+ continue;
+ }
+
+ if (entry.kind == kind or entry.kind == .sym_link) {
+ if (current.dir.statFile(entry.name)) |stat| {
+ if (stat.kind == kind) {
+ try entries.append(allocator, .{
+ .path = try allocator.dupe(u8, entry_path),
+ .mtime = stat.mtime,
+ });
+ }
+ } else |err| {
+ if (err != error.FileNotFound and err != error.AccessDenied) {
+ io.warn("failed to stat {s}: {}\n", .{ entry.name, err });
+ }
+ }
+ }
+
+ if (entry.kind == .directory and current.depth + 1 < max_depth) {
+ const sub_dir = current.dir.openDir(entry.name, .{ .iterate = true }) catch |err| {
+ io.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
+ allocator.free(entry_path);
+ continue;
+ };
+ try stack.append(allocator, .{ .dir = sub_dir, .path = entry_path, .depth = current.depth + 1 });
+ } else {
+ allocator.free(entry_path);
+ }
+ }
+ }
+
+ if (mc) |*m| {
+ if (cache_key) |key| {
+ const json_data = try std.json.Stringify.valueAlloc(allocator, entries.items, .{});
+ defer allocator.free(json_data);
+ try m.set(key, json_data, 3600);
+ }
+ }
+ }
+
+ std.mem.sortUnstable(Entry, entries.items, {}, struct {
+ fn lessThan(_: void, a: Entry, b: Entry) bool {
+ return a.mtime > b.mtime;
+ }
+ }.lessThan);
+
+ var items = std.ArrayListUnmanaged([]const u8){};
+ defer items.deinit(allocator);
+ for (entries.items) |entry| {
+ try items.append(allocator, entry.path);
+ }
+
+ const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
+ defer allocator.free(selected);
+
+ const clean_path = if (std.mem.startsWith(u8, selected, "./")) selected[2..] else selected;
+
+ if (clean_path.len > 0) {
+ std.fs.cwd().access(clean_path, .{}) catch |err| {
+ io.warn("Selected item no longer exists or is inaccessible: {s} ({})\n", .{ clean_path, err });
+ return error.UserAbort;
+ };
+ if (is_dir) {
+ var out_buf: [4096]u8 = undefined;
+ var out = io.getStdout(&out_buf);
+ try out.print("{s}\n", .{clean_path});
+ try out.end();
+ } else {
+ try gotoFile(allocator, db, clean_path, null, config);
+ }
+ }
+}
diff --git a/src/io.zig b/src/io.zig
@@ -0,0 +1,48 @@
+const std = @import("std");
+
+pub const Writer = struct {
+ inner: std.fs.File.Writer,
+ failed: bool = false,
+
+ pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void {
+ if (self.failed) return;
+ self.inner.interface.print(fmt, args) catch |err| {
+ if (err == error.WriteFailed) {
+ self.failed = true;
+ return;
+ }
+ return err;
+ };
+ }
+
+ pub fn end(self: *Writer) !void {
+ if (self.failed) return;
+ self.inner.end() catch |err| {
+ if (err == error.WriteFailed) {
+ self.failed = true;
+ return;
+ }
+ return err;
+ };
+ }
+};
+
+pub fn getStdout(buf: []u8) Writer {
+ return .{ .inner = std.fs.File.stdout().writer(buf) };
+}
+
+pub fn warn(comptime fmt: []const u8, args: anytype) void {
+ var buf: [4096]u8 = undefined;
+ var writer = std.fs.File.stderr().writer(&buf);
+ writer.interface.print(fmt, args) catch |err| {
+ std.log.warn("failed to write to stderr: {}", .{err});
+ };
+ writer.end() catch |err| {
+ std.log.warn("failed to end stderr writer: {}", .{err});
+ };
+}
+
+pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ warn(fmt ++ "\n", args);
+ std.process.exit(1);
+}
diff --git a/src/main.zig b/src/main.zig
@@ -0,0 +1,117 @@
+const std = @import("std");
+const cli = @import("cli.zig");
+const cfg = @import("cfg.zig");
+const fzf = @import("fzf.zig");
+const hist = @import("db.zig");
+const io = @import("io.zig");
+
+pub fn main() void {
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ const args = cli.parseArgs(allocator) catch |err| {
+ io.fatal("failed to parse arguments: {}", .{err});
+ };
+ defer args.deinit();
+
+ if (args.action == .help) {
+ cli.printUsage() catch |err| {
+ io.fatal("failed to print usage: {}", .{err});
+ };
+ return;
+ }
+
+ const parsed_config = cfg.loadConfig(allocator);
+ defer if (parsed_config) |c| c.deinit();
+ const default_config = cfg.Config{};
+ const config = if (parsed_config) |c| c.value else default_config;
+
+ const db_path = hist.getDefaultDbPath(allocator) catch |err| {
+ io.fatal("failed to get database path: {}", .{err});
+ };
+ defer allocator.free(db_path);
+
+ var db = hist.Db.init(allocator, db_path) catch |err| {
+ io.fatal("failed to initialize database: {}", .{err});
+ };
+ defer db.deinit();
+
+ runAction(allocator, &db, args, &config) catch |err| {
+ if (err == error.UserAbort) {
+ std.process.exit(1);
+ }
+ io.fatal("action failed: {}", .{err});
+ };
+}
+
+fn runAction(allocator: std.mem.Allocator, db: *hist.Db, args: cli.Args, config: *const cfg.Config) !void {
+ var buf: [4096]u8 = undefined;
+ var writer = io.getStdout(&buf);
+
+ switch (args.action) {
+ .recent_dirs => {
+ const managed = try db.recentDirs();
+ defer managed.deinit();
+ for (managed.value) |d| {
+ try writer.print("{s}|{s}\n", .{ d.timestamp orelse "-", d.path });
+ }
+ },
+ .recent_files => {
+ const managed = try db.recentFiles();
+ defer managed.deinit();
+ for (managed.value) |f| {
+ try writer.print("{s}|{s}|{s}|{s}\n", .{ f.timestamp orelse "-", f.file_type, f.action, f.path });
+ }
+ },
+ .fav_dirs => {
+ try fzf.favDirs(allocator, config);
+ },
+ .search => {
+ if (args.query) |query| {
+ const managed = try db.searchHistory(query);
+ defer managed.deinit();
+ const results = managed.value;
+
+ for (results.directories) |d| {
+ try writer.print("D|{s}\n", .{d.path});
+ }
+ for (results.files) |f| {
+ try writer.print("F|{s}|{s}|{s}\n", .{ f.file_type, f.action, f.path });
+ }
+ }
+ },
+ .goto_dir => {
+ try fzf.recentDir(allocator, db, config);
+ },
+ .goto_file => {
+ try fzf.recentFile(allocator, db, config);
+ },
+ .look_file => {
+ try fzf.lookFile(allocator, db, config, args.depth);
+ },
+ .look_dir => {
+ try fzf.lookDir(allocator, db, config, args.depth);
+ },
+ .grep => {
+ try fzf.grep(allocator, db, config);
+ },
+ .cleanup => {
+ try db.cleanup();
+ },
+ .log_dir => {
+ if (args.log_path) |path| {
+ try db.logDir(path);
+ }
+ },
+ .log_file => {
+ if (args.log_path) |path| {
+ try db.logFile(path, args.file_type orelse "other", args.file_action orelse "open");
+ }
+ },
+ .help => {
+ try cli.printUsage();
+ },
+ }
+ try writer.end();
+}
diff --git a/src/memcached.zig b/src/memcached.zig
@@ -0,0 +1,230 @@
+const std = @import("std");
+const io = @import("io.zig");
+
+pub const Memcached = struct {
+ allocator: std.mem.Allocator,
+ stream: std.net.Stream,
+ write_buf: [4096]u8 = undefined,
+ write_end: usize = 0,
+ read_buf: [65536]u8 = undefined,
+ read_start: usize = 0,
+ read_end: usize = 0,
+
+ pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Memcached {
+ const address = try std.net.Address.parseIp4(host, port);
+ const stream = try std.net.tcpConnectToAddress(address);
+ return .{
+ .allocator = allocator,
+ .stream = stream,
+ };
+ }
+
+ pub fn deinit(self: *Memcached) void {
+ self.flushWrite() catch |err| {
+ io.warn("failed to flush memcached on deinit: {}\n", .{err});
+ };
+ self.stream.close();
+ }
+
+ fn flushWrite(self: *Memcached) !void {
+ if (self.write_end == 0) return;
+ try self.stream.writeAll(self.write_buf[0..self.write_end]);
+ self.write_end = 0;
+ }
+
+ fn bufferedWrite(self: *Memcached, data: []const u8) !void {
+ if (data.len >= self.write_buf.len) {
+ try self.flushWrite();
+ try self.stream.writeAll(data);
+ return;
+ }
+ const available = self.write_buf.len - self.write_end;
+ if (data.len > available) {
+ try self.flushWrite();
+ }
+ @memcpy(self.write_buf[self.write_end..][0..data.len], data);
+ self.write_end += data.len;
+ }
+
+ const chunk_size = 900 * 1024;
+ const chunk_prefix = "\x00chunked:";
+
+ pub fn set(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void {
+ if (value.len <= chunk_size) {
+ try self.sendSet(key, value, exptime);
+ return;
+ }
+
+ const num_chunks = (value.len + chunk_size - 1) / chunk_size;
+ var meta_buf: [128]u8 = undefined;
+ const meta = try std.fmt.bufPrint(&meta_buf, chunk_prefix ++ "{d}:{d}", .{ num_chunks, value.len });
+ try self.sendSet(key, meta, exptime);
+
+ var i: usize = 0;
+ while (i < num_chunks) : (i += 1) {
+ const start = i * chunk_size;
+ const end = @min((i + 1) * chunk_size, value.len);
+ var chunk_key_buf: [512]u8 = undefined;
+ const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}", .{ key, i });
+ try self.sendSet(chunk_key, value[start..end], exptime);
+ }
+ }
+
+ fn sendSet(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void {
+ var header_buf: [512]u8 = undefined;
+ const header = try std.fmt.bufPrint(&header_buf, "ms {s} {d} T{d} q\r\n", .{ key, value.len, exptime });
+
+ try self.bufferedWrite(header);
+ if (value.len > 0) {
+ try self.bufferedWrite(value);
+ }
+ try self.bufferedWrite("\r\n");
+ }
+
+ fn readLine(self: *Memcached, buf: []u8) !?[]const u8 {
+ while (true) {
+ if (self.read_start < self.read_end) {
+ if (std.mem.indexOfScalar(u8, self.read_buf[self.read_start..self.read_end], '\n')) |newline_offset| {
+ const line_start = self.read_start;
+ const line_end = self.read_start + newline_offset;
+ self.read_start = line_end + 1;
+ const line = self.read_buf[line_start..line_end];
+ const trimmed = std.mem.trim(u8, line, "\r");
+ if (buf.len < trimmed.len) return error.BufferTooSmall;
+ @memcpy(buf[0..trimmed.len], trimmed);
+ return buf[0..trimmed.len];
+ }
+ }
+ if (self.read_start > 0) {
+ const remaining = self.read_end - self.read_start;
+ @memcpy(self.read_buf[0..remaining], self.read_buf[self.read_start..self.read_end]);
+ self.read_end = remaining;
+ self.read_start = 0;
+ }
+ if (self.read_end >= self.read_buf.len) return error.LineTooLong;
+ const n = try self.stream.read(self.read_buf[self.read_end..]);
+ if (n == 0) return null;
+ self.read_end += n;
+ }
+ }
+
+ fn readExact(self: *Memcached, buf: []u8) !void {
+ var offset: usize = 0;
+ while (offset < buf.len) {
+ if (self.read_start < self.read_end) {
+ const available = self.read_end - self.read_start;
+ const needed = buf.len - offset;
+ const to_copy = @min(available, needed);
+ @memcpy(buf[offset..][0..to_copy], self.read_buf[self.read_start..][0..to_copy]);
+ self.read_start += to_copy;
+ offset += to_copy;
+ continue;
+ }
+ self.read_start = 0;
+ self.read_end = 0;
+ const remaining = buf.len - offset;
+ if (remaining >= self.read_buf.len) {
+ const n = try self.stream.read(buf[offset..]);
+ if (n == 0) return error.IncompleteRead;
+ offset += n;
+ } else {
+ const n = try self.stream.read(self.read_buf[0..]);
+ if (n == 0) return error.IncompleteRead;
+ self.read_end = n;
+ }
+ }
+ }
+
+ fn consumeCrlf(self: *Memcached) void {
+ if (self.read_start < self.read_end) {
+ const available = self.read_end - self.read_start;
+ if (available >= 2 and self.read_buf[self.read_start] == '\r' and self.read_buf[self.read_start + 1] == '\n') {
+ self.read_start += 2;
+ return;
+ }
+ if (available >= 1 and self.read_buf[self.read_start] == '\n') {
+ self.read_start += 1;
+ return;
+ }
+ }
+ var discard: [2]u8 = undefined;
+ _ = self.stream.read(&discard) catch return;
+ }
+
+ pub fn get(self: *Memcached, key: []const u8) !?[]u8 {
+ try self.flushWrite();
+ var cmd_buf: [512]u8 = undefined;
+ const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{key});
+ try self.stream.writeAll(cmd);
+
+ var line_buf: [512]u8 = undefined;
+ const line = (try self.readLine(&line_buf)) orelse return null;
+
+ if (std.mem.startsWith(u8, line, "VA ")) {
+ const rest = line[3..];
+ const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+ const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10);
+
+ const data = try self.allocator.alloc(u8, size);
+ errdefer self.allocator.free(data);
+
+ if (size > 0) {
+ try self.readExact(data);
+ }
+
+ self.consumeCrlf();
+
+ if (std.mem.startsWith(u8, data, chunk_prefix)) {
+ defer self.allocator.free(data);
+ return try self.reassembleChunks(key, data);
+ }
+
+ return data;
+ }
+
+ return null;
+ }
+
+ fn reassembleChunks(self: *Memcached, key: []const u8, meta: []const u8) ![]u8 {
+ var it = std.mem.splitScalar(u8, meta[chunk_prefix.len..], ':');
+ const num_chunks_str = it.next() orelse return error.InvalidCacheData;
+ const total_size_str = it.next() orelse return error.InvalidCacheData;
+
+ const num_chunks = try std.fmt.parseInt(usize, num_chunks_str, 10);
+ const total_size = try std.fmt.parseInt(usize, total_size_str, 10);
+
+ const result = try self.allocator.alloc(u8, total_size);
+ errdefer self.allocator.free(result);
+
+ var i: usize = 0;
+ while (i < num_chunks) : (i += 1) {
+ var chunk_key_buf: [512]u8 = undefined;
+ const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}", .{ key, i });
+
+ var cmd_buf: [512]u8 = undefined;
+ const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{chunk_key});
+ try self.stream.writeAll(cmd);
+
+ var line_buf: [512]u8 = undefined;
+ const line = (try self.readLine(&line_buf)) orelse return error.CacheChunkMissing;
+
+ if (!std.mem.startsWith(u8, line, "VA ")) return error.CacheChunkMissing;
+
+ const rest = line[3..];
+ const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len;
+ const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10);
+
+ const chunk_start = i * chunk_size;
+ const chunk_end = @min((i + 1) * chunk_size, total_size);
+ if (size != chunk_end - chunk_start) return error.InvalidChunkSize;
+
+ if (size > 0) {
+ try self.readExact(result[chunk_start..chunk_end]);
+ }
+
+ self.consumeCrlf();
+ }
+
+ return result;
+ }
+};
diff --git a/themes/bat/magdalena.tmTheme b/themes/bat/magdalena.tmTheme
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>name</key>
+ <string>magdalena</string>
+ <key>settings</key>
+ <array>
+ <dict>
+ <key>settings</key>
+ <dict>
+ <key>background</key>
+ <string>#2a1f2e</string>
+ <key>divider</key>
+ <string>#3d2a42</string>
+ <key>foreground</key>
+ <string>#c8b8bf</string>
+ <key>caret</key>
+ <string>#f0dfe5</string>
+ <key>invisibles</key>
+ <string>#3d2a42</string>
+ <key>lineHighlight</key>
+ <string>#3d2a42</string>
+ <key>selection</key>
+ <string>#c084b8</string>
+ <key>selectionForeground</key>
+ <string>#f0dfe5</string>
+ </dict>
+ </dict>
+ <dict>
+ <key>name</key>
+ <string>Comments</string>
+ <key>scope</key>
+ <string>comment</string>
+ <key>settings</key>
+ <dict>
+ <key>foreground</key>
+ <string>#7a6e7a</string>
+ </dict>
+ </dict>
+ <dict>
+ <key>name</key>
+ <string>Keywords</string>
+ <key>scope</key>
+ <string>keyword, storage.type, storage.modifier</string>
+ <key>settings</key>
+ <dict>
+ <key>foreground</key>
+ <string>#c084b8</string>
+ </dict>
+ </dict>
+ <dict>
+ <key>name</key>
+ <string>Strings</string>
+ <key>scope</key>
+ <string>string</string>
+ <key>settings</key>
+ <dict>
+ <key>foreground</key>
+ <string>#e08060</string>
+ </dict>
+ </dict>
+ <dict>
+ <key>name</key>
+ <string>Functions</string>
+ <key>scope</key>
+ <string>entity.name.function, support.function</string>
+ <key>settings</key>
+ <dict>
+ <key>foreground</key>
+ <string>#7a9fd4</string>
+ </dict>
+ </dict>
+ <dict>
+ <key>name</key>
+ <string>Types</string>
+ <key>scope</key>
+ <string>entity.name.type, entity.other.inherited-class, support.type, support.class</string>
+ <key>settings</key>
+ <dict>
+ <key>foreground</key>
+ <string>#c4607a</string>
+ </dict>
+ </dict>
+ </array>
+</dict>
+</plist>