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's not really idiomatic yet
const std = @import("std");
const tk = @import("tokamak");
const FileInfo = struct {
name: []const u8,
is_dir: bool,
size: u64,
};
const Panel = struct {
path: []u8,
files: std.ArrayList(FileInfo),
selected: usize,
fn init(allocator: std.mem.Allocator, path: []const u8) !Panel {
var panel = Panel{
.path = try allocator.dupe(u8, path),
.files = std.ArrayList(FileInfo){},
.selected = 0,
};
try panel.refresh(allocator);
return panel;
}
fn deinit(self: *Panel, allocator: std.mem.Allocator) void {
allocator.free(self.path);
for (self.files.items) |file| {
allocator.free(file.name);
}
self.files.deinit(allocator);
}
fn refresh(self: *Panel, allocator: std.mem.Allocator) !void {
for (self.files.items) |file| {
allocator.free(file.name);
}
self.files.clearRetainingCapacity();
self.selected = 0;
var dir = std.fs.cwd().openDir(self.path, .{ .iterate = true }) catch |err| switch (err) {
error.FileNotFound, error.NotDir, error.AccessDenied => return,
else => return err,
};
defer dir.close();
if (!std.mem.eql(u8, self.path, "/")) {
const parent_info = FileInfo{
.name = try allocator.dupe(u8, ".."),
.is_dir = true,
.size = 0,
};
try self.files.append(allocator, parent_info);
}
var iterator = dir.iterate();
while (try iterator.next()) |entry| {
const name = try allocator.dupe(u8, entry.name);
const file_info = FileInfo{
.name = name,
.is_dir = entry.kind == .directory,
.size = if (entry.kind == .file) blk: {
const file = dir.openFile(entry.name, .{}) catch break :blk 0;
defer file.close();
const stat = file.stat() catch break :blk 0;
break :blk stat.size;
} else 0,
};
try self.files.append(allocator, file_info);
}
std.mem.sort(FileInfo, self.files.items, {}, struct {
fn lessThan(_: void, a: FileInfo, b: FileInfo) bool {
if (a.is_dir != b.is_dir) return a.is_dir;
return std.mem.lessThan(u8, a.name, b.name);
}
}.lessThan);
}
fn navigateUp(self: *Panel) void {
if (self.selected > 0) {
self.selected -= 1;
}
}
fn navigateDown(self: *Panel) void {
if (self.selected + 1 < self.files.items.len) {
self.selected += 1;
}
}
fn getCurrentFile(self: *Panel) ?FileInfo {
if (self.files.items.len == 0) return null;
return self.files.items[self.selected];
}
};
const Commander = struct {
allocator: std.mem.Allocator,
left: Panel,
right: Panel,
active: enum { left, right },
fn init(allocator: std.mem.Allocator) !Commander {
const cwd = try std.process.getCwdAlloc(allocator);
defer allocator.free(cwd);
return Commander{
.allocator = allocator,
.left = try Panel.init(allocator, cwd),
.right = try Panel.init(allocator, cwd),
.active = .left,
};
}
fn deinit(self: *Commander) void {
self.left.deinit(self.allocator);
self.right.deinit(self.allocator);
}
fn getActivePanel(self: *Commander) *Panel {
return switch (self.active) {
.left => &self.left,
.right => &self.right,
};
}
fn getInactivePanel(self: *Commander) *Panel {
return switch (self.active) {
.left => &self.right,
.right => &self.left,
};
}
};
const App = struct {
fn run() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var commander = try Commander.init(allocator);
defer commander.deinit();
// Use TUI context
const ctx = try tk.tui.Context.init(allocator);
defer ctx.deinit();
while (true) {
try displayPanels(&commander, ctx);
try ctx.flush();
const key = try ctx.readKey();
switch (key) {
.char => |c| switch (c) {
'q' => break,
'c' => try copyFile(&commander),
'd' => try deleteFile(&commander),
'm' => try createDirectory(&commander, ctx),
else => {},
},
.ctrl_c => break, // Ctrl-C to exit
.tab => commander.active = if (commander.active == .left) .right else .left,
.up => commander.getActivePanel().navigateUp(),
.down => commander.getActivePanel().navigateDown(),
.left => commander.active = .left,
.right => commander.active = .right,
.enter => try enterDirectory(&commander),
.f5 => try copyFile(&commander), // F5 for copy (like MC)
.f7 => try createDirectory(&commander, ctx), // F7 for mkdir (like MC)
.f8 => try deleteFile(&commander), // F8 for delete (like MC)
else => {},
}
}
}
};
fn displayPanels(commander: *Commander, ctx: *tk.tui.Context) !void {
try ctx.clear();
const left_active = commander.active == .left;
const right_active = commander.active == .right;
const horizontal_line = "─" ** 38;
try ctx.print("┌{s}┬{s}┐", .{ horizontal_line, horizontal_line });
try ctx.println("", .{});
// Use truncateEnd for paths
const left_path = tk.util.truncateEnd(commander.left.path, 38);
const right_path = tk.util.truncateEnd(commander.right.path, 38);
try ctx.println("│{s:<38}│{s:<38}│", .{ left_path, right_path });
try ctx.println("├{s}┼{s}┤", .{ horizontal_line, horizontal_line });
const max_files = 20;
for (0..max_files) |i| {
const left_file = if (i < commander.left.files.items.len) commander.left.files.items[i] else null;
const right_file = if (i < commander.right.files.items.len) commander.right.files.items[i] else null;
const left_marker = if (left_active and i == commander.left.selected) ">" else " ";
const right_marker = if (right_active and i == commander.right.selected) ">" else " ";
const left_text = if (left_file) |file|
if (file.is_dir)
std.fmt.allocPrint(std.heap.page_allocator, "{s}[{s}]", .{ left_marker, file.name }) catch ""
else
std.fmt.allocPrint(std.heap.page_allocator, "{s}{s}", .{ left_marker, file.name }) catch ""
else
"";
const right_text = if (right_file) |file|
if (file.is_dir)
std.fmt.allocPrint(std.heap.page_allocator, "{s}[{s}]", .{ right_marker, file.name }) catch ""
else
std.fmt.allocPrint(std.heap.page_allocator, "{s}{s}", .{ right_marker, file.name }) catch ""
else
"";
try ctx.println("│{s:<38}│{s:<38}│", .{ left_text[0..@min(left_text.len, 38)], right_text[0..@min(right_text.len, 38)] });
if (left_file != null) std.heap.page_allocator.free(left_text);
if (right_file != null) std.heap.page_allocator.free(right_text);
}
try ctx.println("└{s}┴{s}┘", .{ horizontal_line, horizontal_line });
try ctx.println("↑↓: navigate Tab/←→: switch panels Enter: enter dir F5/c: copy F7/m: mkdir F8/d: delete q: quit", .{});
}
fn enterDirectory(commander: *Commander) !void {
const panel = commander.getActivePanel();
const file = panel.getCurrentFile() orelse return;
if (!file.is_dir) return;
if (std.mem.eql(u8, file.name, "..")) {
const parent = std.fs.path.dirname(panel.path) orelse return;
const new_path = try commander.allocator.dupe(u8, parent);
commander.allocator.free(panel.path);
panel.path = new_path;
} else {
const new_path = try std.fs.path.join(commander.allocator, &.{ panel.path, file.name });
commander.allocator.free(panel.path);
panel.path = new_path;
}
try panel.refresh(commander.allocator);
}
fn copyFile(commander: *Commander) !void {
const src_panel = commander.getActivePanel();
const dst_panel = commander.getInactivePanel();
const file = src_panel.getCurrentFile() orelse return;
if (file.is_dir) return;
const src_path = try std.fs.path.join(commander.allocator, &.{ src_panel.path, file.name });
defer commander.allocator.free(src_path);
const dst_path = try std.fs.path.join(commander.allocator, &.{ dst_panel.path, file.name });
defer commander.allocator.free(dst_path);
std.fs.cwd().copyFile(src_path, std.fs.cwd(), dst_path, .{}) catch |err| {
std.debug.print("Copy failed: {}\n", .{err});
const stdin = std.fs.File.stdin();
var buf: [1]u8 = undefined;
_ = stdin.read(&buf) catch {};
return;
};
try dst_panel.refresh(commander.allocator);
}
fn deleteFile(commander: *Commander) !void {
const panel = commander.getActivePanel();
const file = panel.getCurrentFile() orelse return;
const file_path = try std.fs.path.join(commander.allocator, &.{ panel.path, file.name });
defer commander.allocator.free(file_path);
if (file.is_dir) {
std.fs.cwd().deleteDir(file_path) catch |err| {
std.debug.print("Delete failed: {}\n", .{err});
const stdin = std.fs.File.stdin();
var buf: [1]u8 = undefined;
_ = stdin.read(&buf) catch {};
return;
};
} else {
std.fs.cwd().deleteFile(file_path) catch |err| {
std.debug.print("Delete failed: {}\n", .{err});
const stdin = std.fs.File.stdin();
var buf: [1]u8 = undefined;
_ = stdin.read(&buf) catch {};
return;
};
}
try panel.refresh(commander.allocator);
}
fn createDirectory(commander: *Commander, ctx: *tk.tui.Context) !void {
const panel = commander.getActivePanel();
// Show prompt at bottom of screen
const horizontal_line = "─" ** 38;
try ctx.println("└{s}┴{s}┘", .{ horizontal_line, horizontal_line });
try ctx.print("Enter directory name: ", .{});
try ctx.flush();
// Read directory name
var name_buf: [256]u8 = undefined;
const dir_name = try ctx.readLine(&name_buf) orelse return; // User cancelled
if (dir_name.len == 0) return; // Empty name, cancel
const dir_path = try std.fs.path.join(commander.allocator, &.{ panel.path, dir_name });
defer commander.allocator.free(dir_path);
std.fs.cwd().makeDir(dir_path) catch |err| switch (err) {
error.PathAlreadyExists => return, // Directory already exists
else => return err,
};
try panel.refresh(commander.allocator);
}
pub fn main() !void {
try tk.app.run(App.run, &.{App});
}
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 {
path: []u8,
files: std.ArrayList(FileInfo),
selected: usize,
fn init(allocator: std.mem.Allocator, path: []const u8) !Panel {
var panel = Panel{
.path = try allocator.dupe(u8, path),
.files = std.ArrayList(FileInfo){},
.selected = 0,
};
try panel.refresh(allocator);
return panel;
}
Commander
Manages both panels:
const Commander = struct {
allocator: std.mem.Allocator,
left: Panel,
right: Panel,
active: enum { left, right },
fn init(allocator: std.mem.Allocator) !Commander {
const cwd = try std.process.getCwdAlloc(allocator);
defer allocator.free(cwd);
return Commander{
.allocator = allocator,
.left = try Panel.init(allocator, cwd),
.right = try Panel.init(allocator, cwd),
.active = .left,
};
}
Main Loop
while (true) {
try displayPanels(&commander, ctx);
try ctx.flush();
const key = try ctx.readKey();
switch (key) {
.char => |c| switch (c) {
'q' => break,
'c' => try copyFile(&commander),
'd' => try deleteFile(&commander),
'm' => try createDirectory(&commander, ctx),
else => {},
},
.ctrl_c => break, // Ctrl-C to exit
.tab => commander.active = if (commander.active == .left) .right else .left,
.up => commander.getActivePanel().navigateUp(),
.down => commander.getActivePanel().navigateDown(),
.left => commander.active = .left,
.right => commander.active = .right,
.enter => try enterDirectory(&commander),
.f5 => try copyFile(&commander), // F5 for copy (like MC)
.f7 => try createDirectory(&commander, ctx), // F7 for mkdir (like MC)
.f8 => try deleteFile(&commander), // F8 for delete (like MC)
else => {},
}
}
}
TUI Context
The tk.tui.Context provides terminal control:
// Use TUI context
const ctx = try tk.tui.Context.init(allocator);
defer ctx.deinit();
while (true) {
try displayPanels(&commander, ctx);
try ctx.flush();
const key = try ctx.readKey();
File Operations
Copy File
fn copyFile(commander: *Commander) !void {
const src_panel = commander.getActivePanel();
const dst_panel = commander.getInactivePanel();
const file = src_panel.getCurrentFile() orelse return;
if (file.is_dir) return;
const src_path = try std.fs.path.join(commander.allocator, &.{ src_panel.path, file.name });
defer commander.allocator.free(src_path);
const dst_path = try std.fs.path.join(commander.allocator, &.{ dst_panel.path, file.name });
defer commander.allocator.free(dst_path);
std.fs.cwd().copyFile(src_path, std.fs.cwd(), dst_path, .{}) catch |err| {
std.debug.print("Copy failed: {}\n", .{err});
const stdin = std.fs.File.stdin();
var buf: [1]u8 = undefined;
_ = stdin.read(&buf) catch {};
return;
};
try dst_panel.refresh(commander.allocator);
}
Create Directory
fn createDirectory(commander: *Commander, ctx: *tk.tui.Context) !void {
const panel = commander.getActivePanel();
// Show prompt at bottom of screen
const horizontal_line = "─" ** 38;
try ctx.println("└{s}┴{s}┘", .{ horizontal_line, horizontal_line });
try ctx.print("Enter directory name: ", .{});
try ctx.flush();
// Read directory name
var name_buf: [256]u8 = undefined;
const dir_name = try ctx.readLine(&name_buf) orelse return; // User cancelled
if (dir_name.len == 0) return; // Empty name, cancel
const dir_path = try std.fs.path.join(commander.allocator, &.{ panel.path, dir_name });
defer commander.allocator.free(dir_path);
std.fs.cwd().makeDir(dir_path) catch |err| switch (err) {
error.PathAlreadyExists => return, // Directory already exists
else => return err,
};
try panel.refresh(commander.allocator);
}
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