Launch local editor for paths
This commit is contained in:
+252
-22
@@ -25,6 +25,7 @@ const help_text =
|
|||||||
\\mim - mobile-first terminal code editor for SSH sessions
|
\\mim - mobile-first terminal code editor for SSH sessions
|
||||||
\\
|
\\
|
||||||
\\Usage:
|
\\Usage:
|
||||||
|
\\ mim [path]
|
||||||
\\ mim [--help]
|
\\ mim [--help]
|
||||||
\\ mim [--version]
|
\\ mim [--version]
|
||||||
\\ mim context [task] [intent]
|
\\ mim context [task] [intent]
|
||||||
@@ -32,25 +33,25 @@ const help_text =
|
|||||||
\\ mim mimctl context <socket|-> [task] [intent]
|
\\ mim mimctl context <socket|-> [task] [intent]
|
||||||
\\ mim --mimctl <socket|-> <request...>
|
\\ mim --mimctl <socket|-> <request...>
|
||||||
\\
|
\\
|
||||||
\\This early build exposes the canonical smoke path, compact local context,
|
\\With no path, mim opens the current directory. Existing files open in the
|
||||||
\\and a minimal local session-control client for trusted tools.
|
\\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) {
|
const Command = union(enum) {
|
||||||
help,
|
help,
|
||||||
version,
|
version,
|
||||||
smoke,
|
open: []const u8,
|
||||||
unknown: []const u8,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn parseArgs(args: []const []const u8) Command {
|
fn parseArgs(args: []const []const u8) Command {
|
||||||
if (args.len <= 1) return .smoke;
|
if (args.len <= 1) return .{ .open = "." };
|
||||||
|
|
||||||
const arg = args[1];
|
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, "--help") or std.mem.eql(u8, arg, "-h")) return .help;
|
||||||
if (std.mem.eql(u8, arg, "--version")) return .version;
|
if (std.mem.eql(u8, arg, "--version")) return .version;
|
||||||
return .{ .unknown = arg };
|
return .{ .open = arg };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn versionText() []const u8 {
|
fn versionText() []const u8 {
|
||||||
@@ -133,18 +134,200 @@ pub fn main(init: std.process.Init) !u8 {
|
|||||||
switch (command) {
|
switch (command) {
|
||||||
.help => try stdout.writeStreamingAll(init.io, help_text),
|
.help => try stdout.writeStreamingAll(init.io, help_text),
|
||||||
.version => try stdout.writeStreamingAll(init.io, versionText()),
|
.version => try stdout.writeStreamingAll(init.io, versionText()),
|
||||||
.smoke => try stdout.writeStreamingAll(init.io, help_text),
|
.open => |path| try openLocalEditor(allocator, init.io, stdout, stderr, path),
|
||||||
.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;
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
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 {
|
fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void {
|
||||||
const task = args.next();
|
const task = args.next();
|
||||||
const intent = args.next();
|
const intent = args.next();
|
||||||
@@ -245,9 +428,9 @@ test {
|
|||||||
_ = tui;
|
_ = 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, "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 context") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim profile") != 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());
|
try std.testing.expectEqualStrings("mim 0.1.0-dev\n", versionText());
|
||||||
}
|
}
|
||||||
|
|
||||||
test "adversarial: unknown arguments are rejected instead of ignored" {
|
test "regular: no args opens current directory" {
|
||||||
const argv = [_][]const u8{ "mim", "--definitely-not-supported" };
|
const argv = [_][]const u8{"mim"};
|
||||||
const parsed = parseArgs(&argv);
|
const parsed = parseArgs(&argv);
|
||||||
switch (parsed) {
|
switch (parsed) {
|
||||||
.unknown => |arg| try std.testing.expectEqualStrings("--definitely-not-supported", arg),
|
.open => |path| try std.testing.expectEqualStrings(".", path),
|
||||||
else => return error.ExpectedUnknownArgument,
|
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 argv = [_][]const u8{ "mim", "some-file.zig" };
|
||||||
const parsed = parseArgs(&argv);
|
const parsed = parseArgs(&argv);
|
||||||
switch (parsed) {
|
switch (parsed) {
|
||||||
.unknown => |arg| try std.testing.expectEqualStrings("some-file.zig", arg),
|
.open => |path| try std.testing.expectEqualStrings("some-file.zig", path),
|
||||||
else => return error.ExpectedUnknownArgument,
|
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());
|
||||||
|
}
|
||||||
|
|||||||
+17
@@ -327,6 +327,23 @@ pub const Client = struct {
|
|||||||
return self.saved_bytes orelse Error.NothingSaved;
|
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 {
|
fn addRepoFile(self: *Client, payload: []const u8) !void {
|
||||||
const separator = std.mem.indexOfScalar(u8, payload, '=') orelse {
|
const separator = std.mem.indexOfScalar(u8, payload, '=') orelse {
|
||||||
self.message = "diagnostic:unsupported_file:invalid_repo_file_payload";
|
self.message = "diagnostic:unsupported_file:invalid_repo_file_payload";
|
||||||
|
|||||||
Reference in New Issue
Block a user