clown-commander
A terminal-based file manager inspired by Norton Commander and Midnight Commander.
Source Code
Path: examples/clown-commander/
// TODO: This was mostly generated using claude-code so it is not yet idiomatic
const std = @import("std");
const tk = @import("tokamak");
const Builder = tk.tui.Builder;
const DirEntry = std.fs.Dir.Entry;
const Panel = struct {
gpa: std.mem.Allocator,
path: []u8,
files: std.ArrayList(DirEntry) = .{},
selected: usize = 0,
fn refresh(self: *Panel) !void {
for (self.files.items) |f| tk.meta.free(self.gpa, f);
self.files.clearRetainingCapacity();
self.selected = 0;
if (!std.mem.eql(u8, self.path, "/")) {
try self.files.append(self.gpa, .{ .name = try self.gpa.dupe(u8, ".."), .kind = .directory });
}
var dir = try std.fs.cwd().openDir(self.path, .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
try self.files.append(self.gpa, try tk.meta.dupe(self.gpa, entry));
}
std.mem.sort(DirEntry, self.files.items, {}, struct {
fn lt(_: void, a: DirEntry, b: DirEntry) bool {
if (a.kind != b.kind) return a.kind == .directory;
return std.mem.lessThan(u8, a.name, b.name);
}
}.lt);
}
fn current(self: *const Panel) ?DirEntry {
if (self.files.items.len == 0) return null;
return self.files.items[self.selected];
}
fn enter(self: *Panel, name: []const u8) !void {
const new_path = if (std.mem.eql(u8, name, ".."))
try self.gpa.dupe(u8, std.fs.path.dirname(self.path) orelse return)
else
try std.fs.path.join(self.gpa, &.{ self.path, name });
self.gpa.free(self.path);
self.path = new_path;
try self.refresh();
}
};
const Commander = struct {
panels: [2]Panel,
active: usize = 0,
mkdir_buf: [256]u8 = undefined,
mkdir_len: usize = 0,
mkdir_active: bool = false,
quit: bool = false,
fn init(allocator: std.mem.Allocator, path: []const u8) !Commander {
var panels = [2]Panel{
.{ .gpa = allocator, .path = try allocator.dupe(u8, path) },
.{ .gpa = allocator, .path = try allocator.dupe(u8, path) },
};
try panels[0].refresh();
try panels[1].refresh();
return .{ .panels = panels };
}
fn deinit(self: *Commander) void {
for (&self.panels) |*p| {
p.gpa.free(p.path);
for (p.files.items) |f| p.gpa.free(f.name);
p.files.deinit(p.gpa);
}
}
fn activePanel(self: *Commander) *Panel {
return &self.panels[self.active];
}
fn inactivePanel(self: *Commander) *Panel {
return &self.panels[1 - self.active];
}
};
// --- App state ---
var cmd: Commander = undefined;
// --- UI ---
fn app(ui: Builder) void {
if (ui.pushEq(2, -1)) |g| {
filePanel(g, &cmd, &cmd.panels[0]);
filePanel(g, &cmd, &cmd.panels[1]);
}
if (cmd.mkdir_active) {
if (ui.modal(&cmd.mkdir_active, "New directory name", 40, 5)) |m| {
m.textInput(&cmd.mkdir_buf, &cmd.mkdir_len);
if (m.row(&.{ 10, 10 })) |r| {
if (r.button("OK")) {
confirmMkdir();
cmd.mkdir_active = false;
cmd.mkdir_len = 0;
}
if (r.button("Cancel")) {
cmd.mkdir_active = false;
cmd.mkdir_len = 0;
}
}
}
ui.statusBar("New directory name (Enter: confirm Esc: cancel)");
} else {
if (ui.menu(6)) |m| {
if (m.item(.enter, "open")) openEntry();
_ = m.item(.tab, "switch"); // fileList() is control, so tab just works
if (m.item(.f5, "copy")) copyFile();
if (m.item(.f7, "mkdir")) {
cmd.mkdir_active = true;
cmd.mkdir_len = 0;
@memset(&cmd.mkdir_buf, 0);
ui.ctx.focus = 2;
}
if (m.item(.f8, "delete")) deleteEntry();
if (m.item(.f10, "quit")) cmd.quit = true;
}
}
}
fn filePanel(ui: Builder, commander: *Commander, panel: *Panel) void {
if (ui.panel(-1)) |p| {
p.container().layout.spacing = 0;
p.label(panel.path);
p.separator();
fileList(p, commander, panel, -2);
}
}
fn fileList(ui: Builder, commander: *Commander, panel: *Panel, height: i32) void {
const inner = ui.stack(height) orelse return;
inner.container().layout.spacing = 0;
const visible: usize = @intCast(@max(0, inner.frame.height()));
const scroll: usize = if (panel.selected >= visible) panel.selected - visible + 1 else 0;
const ctrl = ui.control(&panel.selected);
ctrl.navigate(.{ .up, .down }, panel.files.items.len);
if (ctrl.focused) {
commander.active = if (panel == &commander.panels[0]) 0 else 1;
}
var i: usize = scroll;
while (i < panel.files.items.len and i < scroll + visible) : (i += 1) {
var f = inner.next(-1, 1) orelse return;
const is_sel = i == panel.selected;
if (ctrl.focused and is_sel) f.fg = ui.ctx.theme.primary;
if (is_sel) f.fill(ui.ctx.theme.primary);
const file = panel.files.items[i];
if (file.kind == .directory) {
if (is_sel) f.fg = ui.ctx.theme.text;
f.text(ui.ctx.fmt("/{s}", .{file.name}));
} else {
f.fg = if (is_sel) ui.ctx.theme.text else ui.ctx.theme.secondary;
f.text(file.name);
}
}
}
// --- Operations ---
fn openEntry() void {
const panel = cmd.activePanel();
const file = panel.current() orelse return;
if (file.kind != .directory) return;
panel.enter(file.name) catch {};
}
fn copyFile() void {
const src = cmd.activePanel();
const dst = cmd.inactivePanel();
const file = src.current() orelse return;
if (file.kind == .directory) return;
const src_path = std.fs.path.join(src.gpa, &.{ src.path, file.name }) catch return;
defer src.gpa.free(src_path);
const dst_path = std.fs.path.join(dst.gpa, &.{ dst.path, file.name }) catch return;
defer dst.gpa.free(dst_path);
std.fs.cwd().copyFile(src_path, std.fs.cwd(), dst_path, .{}) catch return;
dst.refresh() catch {};
}
fn deleteEntry() void {
const panel = cmd.activePanel();
const file = panel.current() orelse return;
const path = std.fs.path.join(panel.gpa, &.{ panel.path, file.name }) catch return;
defer panel.gpa.free(path);
if (file.kind == .directory) {
std.fs.cwd().deleteDir(path) catch {};
} else {
std.fs.cwd().deleteFile(path) catch {};
}
panel.refresh() catch {};
}
fn confirmMkdir() void {
const name = cmd.mkdir_buf[0..cmd.mkdir_len];
if (name.len == 0) return;
const panel = cmd.activePanel();
const path = std.fs.path.join(panel.gpa, &.{ panel.path, name }) catch return;
defer panel.gpa.free(path);
std.fs.cwd().makeDir(path) catch {};
panel.refresh() catch {};
}
// --- Entry point ---
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const cwd = try std.process.getCwdAlloc(allocator);
defer allocator.free(cwd);
cmd = try Commander.init(allocator, cwd);
defer cmd.deinit();
var cx = try tk.tui.Context.init(allocator);
defer cx.deinit();
while (!cmd.quit) {
switch (try cx.tick()) {
.render => |ui| app(ui),
.key => |k| switch (k) {
.ctrl_c => cmd.quit = true,
else => cx.pending_key = k,
},
else => {},
}
}
}
Features Demonstrated
- TUI (Terminal User Interface) framework
- Dual-panel file navigation
- Keyboard event handling
- File system operations (copy, delete, mkdir)
- Interactive user input
- ANSI escape codes and terminal control
Controls
| Key | Action |
|---|---|
| ↑ ↓ | Navigate up/down in current panel |
| ← → | Switch to left/right panel |
| Tab | Toggle between panels |
| Enter | Enter directory (or parent if on ..) |
| F5 or 'c' | Copy selected file to other panel |
| F7 or 'm' | Create new directory |
| F8 or 'd' | Delete selected file/directory |
| 'q' or Ctrl-C | Quit application |
Architecture
Panel
Each panel manages its own state:
const Panel = struct {
gpa: std.mem.Allocator,
path: []u8,
files: std.ArrayList(DirEntry) = .{},
selected: usize = 0,
fn refresh(self: *Panel) !void {
for (self.files.items) |f| tk.meta.free(self.gpa, f);
self.files.clearRetainingCapacity();
self.selected = 0;
if (!std.mem.eql(u8, self.path, "/")) {
try self.files.append(self.gpa, .{ .name = try self.gpa.dupe(u8, ".."), .kind = .directory });
}
var dir = try std.fs.cwd().openDir(self.path, .{ .iterate = true });
defer dir.close();
var it = dir.iterate();
while (try it.next()) |entry| {
try self.files.append(self.gpa, try tk.meta.dupe(self.gpa, entry));
}
std.mem.sort(DirEntry, self.files.items, {}, struct {
fn lt(_: void, a: DirEntry, b: DirEntry) bool {
if (a.kind != b.kind) return a.kind == .directory;
return std.mem.lessThan(u8, a.name, b.name);
}
}.lt);
}
fn current(self: *const Panel) ?DirEntry {
if (self.files.items.len == 0) return null;
return self.files.items[self.selected];
}
fn enter(self: *Panel, name: []const u8) !void {
const new_path = if (std.mem.eql(u8, name, ".."))
try self.gpa.dupe(u8, std.fs.path.dirname(self.path) orelse return)
else
try std.fs.path.join(self.gpa, &.{ self.path, name });
self.gpa.free(self.path);
self.path = new_path;
try self.refresh();
}
};
Commander
Manages both panels:
const Commander = struct {
panels: [2]Panel,
active: usize = 0,
mkdir_buf: [256]u8 = undefined,
mkdir_len: usize = 0,
mkdir_active: bool = false,
quit: bool = false,
fn init(allocator: std.mem.Allocator, path: []const u8) !Commander {
var panels = [2]Panel{
.{ .gpa = allocator, .path = try allocator.dupe(u8, path) },
.{ .gpa = allocator, .path = try allocator.dupe(u8, path) },
};
try panels[0].refresh();
try panels[1].refresh();
return .{ .panels = panels };
}
fn deinit(self: *Commander) void {
for (&self.panels) |*p| {
p.gpa.free(p.path);
for (p.files.items) |f| p.gpa.free(f.name);
p.files.deinit(p.gpa);
}
}
fn activePanel(self: *Commander) *Panel {
return &self.panels[self.active];
}
fn inactivePanel(self: *Commander) *Panel {
return &self.panels[1 - self.active];
}
};
Main Loop
while (!cmd.quit) {
switch (try cx.tick()) {
.render => |ui| app(ui),
.key => |k| switch (k) {
.ctrl_c => cmd.quit = true,
else => cx.pending_key = k,
},
else => {},
}
}
TUI Context
The tk.tui.Context provides terminal control:
var cx = try tk.tui.Context.init(allocator);
defer cx.deinit();
File Operations
Copy File
fn copyFile() void {
const src = cmd.activePanel();
const dst = cmd.inactivePanel();
const file = src.current() orelse return;
if (file.kind == .directory) return;
const src_path = std.fs.path.join(src.gpa, &.{ src.path, file.name }) catch return;
defer src.gpa.free(src_path);
const dst_path = std.fs.path.join(dst.gpa, &.{ dst.path, file.name }) catch return;
defer dst.gpa.free(dst_path);
std.fs.cwd().copyFile(src_path, std.fs.cwd(), dst_path, .{}) catch return;
dst.refresh() catch {};
}
Create Directory
fn confirmMkdir() void {
const name = cmd.mkdir_buf[0..cmd.mkdir_len];
if (name.len == 0) return;
const panel = cmd.activePanel();
const path = std.fs.path.join(panel.gpa, &.{ panel.path, name }) catch return;
defer panel.gpa.free(path);
std.fs.cwd().makeDir(path) catch {};
panel.refresh() catch {};
}
Display Layout
The interface uses box-drawing characters for a clean TUI:
┌──────────────────────────────────────┬──────────────────────────────────────┐
│/home/user/project │/home/user/downloads │
├──────────────────────────────────────┼──────────────────────────────────────┤
│>[..] │ [..] │
│ [src] │ [documents] │
│ [test] │>[music] │
│ main.zig │ file.txt │
│ README.md │ image.png │
└──────────────────────────────────────┴──────────────────────────────────────┘
↑↓: navigate Tab/←→: switch panels Enter: enter dir F5/c: copy q: quit
Running
cd examples/clown-commander
zig build run
The application will launch in your terminal with a dual-panel file browser.
Tips
- Directories are shown with
[brackets] - The active panel's selected item is marked with
> - Use
..to navigate to parent directory - The app starts in your current working directory