diff --git a/src/main.zig b/src/main.zig index c8561d4..597f351 100644 --- a/src/main.zig +++ b/src/main.zig @@ -25,6 +25,7 @@ const help_text = \\mim - mobile-first terminal code editor for SSH sessions \\ \\Usage: + \\ mim [path] \\ mim [--help] \\ mim [--version] \\ mim context [task] [intent] @@ -32,25 +33,25 @@ const help_text = \\ mim mimctl context [task] [intent] \\ mim --mimctl \\ - \\This early build exposes the canonical smoke path, compact local context, - \\and a minimal local session-control client for trusted tools. + \\With no path, mim opens the current directory. Existing files open in the + \\editor; missing paths start a new buffer; directories open the file browser. + \\This early build also exposes compact local context and mimctl for trusted tools. \\ ; const Command = union(enum) { help, version, - smoke, - unknown: []const u8, + open: []const u8, }; fn parseArgs(args: []const []const u8) Command { - if (args.len <= 1) return .smoke; + if (args.len <= 1) return .{ .open = "." }; const arg = args[1]; if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) return .help; if (std.mem.eql(u8, arg, "--version")) return .version; - return .{ .unknown = arg }; + return .{ .open = arg }; } fn versionText() []const u8 { @@ -133,18 +134,200 @@ pub fn main(init: std.process.Init) !u8 { switch (command) { .help => try stdout.writeStreamingAll(init.io, help_text), .version => try stdout.writeStreamingAll(init.io, versionText()), - .smoke => try stdout.writeStreamingAll(init.io, help_text), - .unknown => |arg| { - try stderr.writeStreamingAll(init.io, "mim: unknown argument '"); - try stderr.writeStreamingAll(init.io, arg); - try stderr.writeStreamingAll(init.io, "'\nTry 'mim --help'.\n"); - return 64; - }, + .open => |path| try openLocalEditor(allocator, init.io, stdout, stderr, path), } return 0; } +fn openLocalEditor( + allocator: std.mem.Allocator, + io: std.Io, + stdout: std.Io.File, + stderr: std.Io.File, + path: []const u8, +) !void { + var client = try tui.Client.init(allocator, .{ .width = 80, .height = 22 }); + defer client.deinit(); + + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch |err| switch (err) { + error.FileNotFound => null, + else => { + try stderr.writeStreamingAll(io, "mim: could not inspect path\n"); + return err; + }, + }; + + if (stat) |metadata| { + switch (metadata.kind) { + .directory => try openDirectoryPreview(allocator, io, &client, path), + .file => { + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(diagnostics.max_file_bytes)) catch |err| switch (err) { + error.StreamTooLong => { + try client.handleTraceLine("open diagnostic:file_too_large"); + return runLocalEditor(allocator, io, stdout, stderr, &client, path, false); + }, + else => return err, + }; + defer allocator.free(bytes); + const command = try std.fmt.allocPrint(allocator, "open {s}", .{bytes}); + defer allocator.free(command); + try client.handleTraceLine(command); + }, + else => try client.handleTraceLine("open diagnostic:unsupported_file"), + } + } else { + try client.handleTraceLine("open "); + } + + try runLocalEditor(allocator, io, stdout, stderr, &client, path, stat != null and stat.?.kind == .directory); +} + +fn openDirectoryPreview(allocator: std.mem.Allocator, io: std.Io, client: *tui.Client, path: []const u8) !void { + var dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }); + defer dir.close(io); + + var iterator = dir.iterate(); + var count: usize = 0; + while (try iterator.next(io)) |entry| { + if (count >= 80) break; + if (entry.kind != .file and entry.kind != .directory) continue; + const suffix = if (entry.kind == .directory) "/" else ""; + const command = try std.fmt.allocPrint(allocator, "repo_file {s}{s}=", .{ entry.name, suffix }); + defer allocator.free(command); + client.handleTraceLine(command) catch {}; + count += 1; + } + try client.handleTraceLine("file_picker"); +} + +fn runLocalEditor( + allocator: std.mem.Allocator, + io: std.Io, + stdout: std.Io.File, + stderr: std.Io.File, + client: *tui.Client, + path: []const u8, + is_dir: bool, +) !void { + const stdin_file = std.Io.File.stdin(); + const interactive = stdin_file.isTty(io) catch false; + + var last_saved = try client.snapshotBytesAlloc(allocator); + defer allocator.free(last_saved); + var last_client_saved: ?[]u8 = null; + defer if (last_client_saved) |bytes| allocator.free(bytes); + + if (!interactive) { + try renderLocalFrame(allocator, io, stdout, client, path, is_dir, false); + return; + } + + var stdin_buffer: [4096]u8 = undefined; + var stdin_reader = stdin_file.readerStreaming(io, &stdin_buffer); + const stdin = &stdin_reader.interface; + + while (true) { + try renderLocalFrame(allocator, io, stdout, client, path, is_dir, true); + const raw = try readEditorInputAlloc(allocator, stdin); + defer if (raw) |bytes| allocator.free(bytes); + if (raw == null) return; + + client.handleInput(raw.?) catch |err| { + if (err == tui.Error.ClientQuit) return; + try stderr.writeStreamingAll(io, "mim: input failed: "); + try stderr.writeStreamingAll(io, @errorName(err)); + try stderr.writeStreamingAll(io, "\n"); + continue; + }; + + const after = try client.snapshotBytesAlloc(allocator); + defer allocator.free(after); + var dirty = !std.mem.eql(u8, after, last_saved); + if (client.saved()) |saved_bytes| { + const new_save_request = last_client_saved == null or !std.mem.eql(u8, saved_bytes, last_client_saved.?); + if (new_save_request) { + if (is_dir) { + client.setStatusMessage("directory browser has nothing to save"); + } else { + try persistLocalSave(allocator, io, path, saved_bytes, &last_saved, &last_client_saved); + dirty = false; + client.setStatusMessage("saved"); + } + } + } else |_| {} + + if (client.requestedQuit()) { + if (dirty) { + client.clearQuit(); + client.setStatusMessage("dirty buffer: Space s saves, quit blocked"); + continue; + } + return; + } + } +} + +fn persistLocalSave( + allocator: std.mem.Allocator, + io: std.Io, + path: []const u8, + bytes: []const u8, + last_saved: *[]u8, + last_client_saved: *?[]u8, +) !void { + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = bytes }); + const saved_copy = try allocator.dupe(u8, bytes); + errdefer allocator.free(saved_copy); + const client_copy = try allocator.dupe(u8, bytes); + allocator.free(last_saved.*); + last_saved.* = saved_copy; + if (last_client_saved.*) |old| allocator.free(old); + last_client_saved.* = client_copy; +} + +fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[]u8 { + const first = stdin.takeByte() catch |err| switch (err) { + error.EndOfStream => return null, + else => return err, + }; + var bytes = std.ArrayList(u8).empty; + errdefer bytes.deinit(allocator); + try bytes.append(allocator, first); + + if (first == 0x1b) { + const second = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator); + try bytes.append(allocator, second); + if (second == '[') { + const third = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator); + try bytes.append(allocator, third); + } + return try bytes.toOwnedSlice(allocator); + } + + const len = std.unicode.utf8ByteSequenceLength(first) catch 1; + var i: usize = 1; + while (i < len) : (i += 1) { + const next = stdin.takeByte() catch break; + try bytes.append(allocator, next); + } + return try bytes.toOwnedSlice(allocator); +} + +fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, client: *tui.Client, path: []const u8, is_dir: bool, interactive: bool) !void { + const frame = try client.render(allocator); + defer allocator.free(frame); + if (interactive) try stdout.writeStreamingAll(io, "\x1b[H\x1b[2J"); + try stdout.writeStreamingAll(io, frame); + try stdout.writeStreamingAll(io, "\n\n"); + try stdout.writeStreamingAll(io, "mim opened: "); + try stdout.writeStreamingAll(io, path); + try stdout.writeStreamingAll(io, if (is_dir) + "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" + else + "\nEditor. Type to insert; arrows move; Space shows commands; Space s saves; Space q quits when clean.\n"); +} + fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void { const task = args.next(); const intent = args.next(); @@ -245,9 +428,9 @@ test { _ = tui; } -test "regular: help text names the binary and smoke boundary" { +test "regular: help text names the binary and open path" { try std.testing.expect(std.mem.indexOf(u8, help_text, "mim - mobile-first terminal code editor") != null); - try std.testing.expect(std.mem.indexOf(u8, help_text, "canonical smoke path") != null); + try std.testing.expect(std.mem.indexOf(u8, help_text, "mim [path]") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "mim context") != null); try std.testing.expect(std.mem.indexOf(u8, help_text, "mim profile") != null); } @@ -264,20 +447,67 @@ test "regular: version output is stable enough for smoke checks" { try std.testing.expectEqualStrings("mim 0.1.0-dev\n", versionText()); } -test "adversarial: unknown arguments are rejected instead of ignored" { - const argv = [_][]const u8{ "mim", "--definitely-not-supported" }; +test "regular: no args opens current directory" { + const argv = [_][]const u8{"mim"}; const parsed = parseArgs(&argv); switch (parsed) { - .unknown => |arg| try std.testing.expectEqualStrings("--definitely-not-supported", arg), - else => return error.ExpectedUnknownArgument, + .open => |path| try std.testing.expectEqualStrings(".", path), + else => return error.ExpectedOpenPath, } } -test "adversarial: extra positional arguments are not treated as files yet" { +test "regular: positional path opens file directory or new buffer" { const argv = [_][]const u8{ "mim", "some-file.zig" }; const parsed = parseArgs(&argv); switch (parsed) { - .unknown => |arg| try std.testing.expectEqualStrings("some-file.zig", arg), - else => return error.ExpectedUnknownArgument, + .open => |path| try std.testing.expectEqualStrings("some-file.zig", path), + else => return error.ExpectedOpenPath, } } + +test "regular: local editor input reader preserves text utf8 and arrows" { + var ascii = std.Io.Reader.fixed("a"); + const ascii_event = (try readEditorInputAlloc(std.testing.allocator, &ascii)).?; + defer std.testing.allocator.free(ascii_event); + try std.testing.expectEqualStrings("a", ascii_event); + + var utf8 = std.Io.Reader.fixed("é"); + const utf8_event = (try readEditorInputAlloc(std.testing.allocator, &utf8)).?; + defer std.testing.allocator.free(utf8_event); + try std.testing.expectEqualStrings("é", utf8_event); + + var arrow = std.Io.Reader.fixed("\x1b[D"); + const arrow_event = (try readEditorInputAlloc(std.testing.allocator, &arrow)).?; + defer std.testing.allocator.free(arrow_event); + try std.testing.expectEqualStrings("\x1b[D", arrow_event); +} + +test "regular: local editor client exposes save and dirty quit guards" { + var client = try tui.Client.init(std.testing.allocator, .{ .width = 32, .height = 8 }); + defer client.deinit(); + + try client.handleTraceLine("open "); + const initial = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(initial); + try std.testing.expectEqualStrings("", initial); + + try client.handleInput("h"); + try client.handleInput("e"); + try client.handleInput("l"); + try client.handleInput("l"); + try client.handleInput("o"); + const edited = try client.snapshotBytesAlloc(std.testing.allocator); + defer std.testing.allocator.free(edited); + try std.testing.expectEqualStrings("hello", edited); + + try client.handleInput(" "); + try client.handleInput("s"); + try std.testing.expectEqualStrings("hello", try client.saved()); + + try client.handleInput("!"); + try client.handleInput(" "); + try client.handleInput("q"); + try std.testing.expect(client.requestedQuit()); + client.clearQuit(); + try std.testing.expect(!client.requestedQuit()); +} diff --git a/src/tui.zig b/src/tui.zig index 4ddea77..7a0fbac 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -327,6 +327,23 @@ pub const Client = struct { return self.saved_bytes orelse Error.NothingSaved; } + pub fn snapshotBytesAlloc(self: *const Client, allocator: std.mem.Allocator) ![]u8 { + const snap = try self.session.snapshot(); + return allocator.dupe(u8, snap.bytes); + } + + pub fn requestedQuit(self: *const Client) bool { + return self.quit; + } + + pub fn clearQuit(self: *Client) void { + self.quit = false; + } + + pub fn setStatusMessage(self: *Client, message: []const u8) void { + self.message = message; + } + fn addRepoFile(self: *Client, payload: []const u8) !void { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { self.message = "diagnostic:unsupported_file:invalid_repo_file_payload";