diff --git a/src/Project.zig b/src/Project.zig index 415a172..ff730d3 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -19,6 +19,7 @@ longest_file_path: usize = 0, open_time: i64, language_servers: std.StringHashMap(LSP), file_language_server: std.StringHashMap(LSP), +tasks: std.ArrayList(Task), const Self = @This(); @@ -39,6 +40,11 @@ const File = struct { visited: bool = false, }; +const Task = struct { + command: []const u8, + mtime: i64, +}; + pub fn init(allocator: std.mem.Allocator, name: []const u8) OutOfMemoryError!Self { return .{ .allocator = allocator, @@ -48,6 +54,7 @@ pub fn init(allocator: std.mem.Allocator, name: []const u8) OutOfMemoryError!Sel .open_time = std.time.milliTimestamp(), .language_servers = std.StringHashMap(LSP).init(allocator), .file_language_server = std.StringHashMap(LSP).init(allocator), + .tasks = std.ArrayList(Task).init(allocator), }; } @@ -63,10 +70,39 @@ pub fn deinit(self: *Self) void { } for (self.files.items) |file| self.allocator.free(file.path); self.files.deinit(); + for (self.tasks.items) |task| self.allocator.free(task.command); + self.tasks.deinit(); self.allocator.free(self.name); } pub fn write_state(self: *Self, writer: anytype) !void { + return self.write_state_v1(writer); +} + +pub fn write_state_v1(self: *Self, writer: anytype) !void { + try cbor.writeValue(writer, self.name); + var visited: usize = 0; + for (self.files.items) |file| { + if (file.visited) visited += 1; + } + try cbor.writeArrayHeader(writer, visited); + for (self.files.items) |file| { + if (!file.visited) continue; + try cbor.writeArrayHeader(writer, 4); + try cbor.writeValue(writer, file.path); + try cbor.writeValue(writer, file.mtime); + try cbor.writeValue(writer, file.row); + try cbor.writeValue(writer, file.col); + } + try cbor.writeArrayHeader(writer, self.tasks.items.len); + for (self.tasks.items) |task| { + try cbor.writeArrayHeader(writer, 2); + try cbor.writeValue(writer, task.command); + try cbor.writeValue(writer, task.mtime); + } +} + +pub fn write_state_v0(self: *Self, writer: anytype) !void { try cbor.writeValue(writer, self.name); for (self.files.items) |file| { if (!file.visited) continue; @@ -79,6 +115,66 @@ pub fn write_state(self: *Self, writer: anytype) !void { } pub fn restore_state(self: *Self, data: []const u8) !void { + defer self.sort_files_by_mtime(); + defer self.sort_tasks_by_mtime(); + var iter: []const u8 = data; + _ = cbor.matchValue(&iter, tp.string) catch {}; + _ = cbor.decodeArrayHeader(&iter) catch |e| switch (e) { + error.InvalidType => return self.restore_state_v0(data), + else => return e, + }; + return self.restore_state_v1(data); +} + +pub fn restore_state_v1(self: *Self, data: []const u8) !void { + var iter: []const u8 = data; + + var name: []const u8 = undefined; + _ = cbor.matchValue(&iter, tp.extract(&name)) catch {}; + + var files = try cbor.decodeArrayHeader(&iter); + while (files > 0) : (files -= 1) { + var path: []const u8 = undefined; + var mtime: i128 = undefined; + var row: usize = undefined; + var col: usize = undefined; + if (!try cbor.matchValue(&iter, .{ + tp.extract(&path), + tp.extract(&mtime), + tp.extract(&row), + tp.extract(&col), + })) { + try cbor.skipValue(&iter); + continue; + } + self.longest_file_path = @max(self.longest_file_path, path.len); + const stat = std.fs.cwd().statFile(path) catch return; + switch (stat.kind) { + .sym_link, .file => {}, + else => return, + } + try self.update_mru_internal(path, mtime, row, col); + } + + var tasks = try cbor.decodeArrayHeader(&iter); + while (tasks > 0) : (tasks -= 1) { + var command: []const u8 = undefined; + var mtime: i64 = undefined; + if (!try cbor.matchValue(&iter, .{ + tp.extract(&command), + tp.extract(&mtime), + })) { + try cbor.skipValue(&iter); + continue; + } + (try self.tasks.addOne()).* = .{ + .command = try self.allocator.dupe(u8, command), + .mtime = mtime, + }; + } +} + +pub fn restore_state_v0(self: *Self, data: []const u8) !void { defer self.sort_files_by_mtime(); var name: []const u8 = undefined; var path: []const u8 = undefined; @@ -160,12 +256,19 @@ fn make_URI(self: *Self, file_path: ?[]const u8) LspError![]const u8 { } pub fn sort_files_by_mtime(self: *Self) void { - const less_fn = struct { - fn less_fn(_: void, lhs: File, rhs: File) bool { + sort_by_mtime(File, self.files.items); +} + +pub fn sort_tasks_by_mtime(self: *Self) void { + sort_by_mtime(Task, self.tasks.items); +} + +inline fn sort_by_mtime(T: type, items: []T) void { + std.mem.sort(T, items, {}, struct { + fn cmp(_: void, lhs: T, rhs: T) bool { return lhs.mtime > rhs.mtime; } - }.less_fn; - std.mem.sort(File, self.files.items, {}, less_fn); + }.cmp); } pub fn request_n_most_recent_file(self: *Self, from: tp.pid_ref, n: usize) ClientError!void { @@ -302,6 +405,37 @@ pub fn get_mru_position(self: *Self, from: tp.pid_ref, file_path: []const u8) Cl } } +pub fn request_tasks(self: *Self, from: tp.pid_ref) ClientError!void { + var message = std.ArrayList(u8).init(self.allocator); + const writer = message.writer(); + try cbor.writeArrayHeader(writer, self.tasks.items.len); + for (self.tasks.items) |task| + try cbor.writeValue(writer, task.command); + from.send_raw(.{ .buf = message.items }) catch return error.ClientFailed; +} + +pub fn add_task(self: *Self, command: []const u8) OutOfMemoryError!void { + defer self.sort_tasks_by_mtime(); + for (self.tasks.items) |*task| + if (std.mem.eql(u8, task.command, command)) { + task.mtime = std.time.milliTimestamp(); + return; + }; + (try self.tasks.addOne()).* = .{ + .command = try self.allocator.dupe(u8, command), + .mtime = std.time.milliTimestamp(), + }; +} + +pub fn delete_task(self: *Self, command: []const u8) error{}!void { + for (self.tasks.items, 0..) |task, i| + if (std.mem.eql(u8, task.command, command)) { + const removed = self.tasks.orderedRemove(i); + self.allocator.free(removed.command); + return; + }; +} + pub fn did_open(self: *Self, file_path: []const u8, file_type: []const u8, language_server: []const u8, version: usize, text: []const u8) StartLspError!void { self.update_mru(file_path, 0, 0) catch {}; const lsp = try self.get_or_start_language_server(file_path, language_server); diff --git a/src/keybind/builtin/flow.json b/src/keybind/builtin/flow.json index b3399a9..3e7aaf8 100644 --- a/src/keybind/builtin/flow.json +++ b/src/keybind/builtin/flow.json @@ -1,6 +1,7 @@ { "project": { "press": [ + ["alt+!", "select_task"], ["ctrl+tab", "next_tab"], ["ctrl+shift+tab", "previous_tab"], ["ctrl+shift+e", "switch_buffers"], @@ -317,6 +318,25 @@ ["backspace", "mini_mode_cancel"] ] }, + "mini/buffer": { + "press": [ + ["ctrl+q", "quit"], + ["ctrl+v", "system_paste"], + ["ctrl+u", "mini_mode_reset"], + ["ctrl+g", "mini_mode_cancel"], + ["ctrl+c", "mini_mode_cancel"], + ["ctrl+l", "scroll_view_center_cycle"], + ["ctrl+i", "mini_mode_insert_bytes", "\t"], + ["ctrl+space", "mini_mode_cancel"], + ["ctrl+backspace", "mini_mode_reset"], + ["alt+v", "system_paste"], + ["alt+shift+v", "system_paste"], + ["tab", "mini_mode_try_complete_file"], + ["escape", "mini_mode_cancel"], + ["enter", "mini_mode_select"], + ["backspace", "mini_mode_delete_backwards"] + ] + }, "mini/file_browser": { "press": [ ["ctrl+q", "quit"], diff --git a/src/project_manager.zig b/src/project_manager.zig index 9a71da8..9339a2f 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -104,6 +104,27 @@ pub fn request_path_files(max: usize, path: []const u8) (ProjectManagerError || return send(.{ "request_path_files", project, max, path }); } +pub fn request_tasks(allocator: std.mem.Allocator) (ProjectError || CallError)!tp.message { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return (try get()).pid.call(allocator, request_timeout, .{ "request_tasks", project }); +} + +pub fn add_task(task: []const u8) (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "add_task", project, task }); +} + +pub fn delete_task(task: []const u8) (ProjectManagerError || ProjectError)!void { + const project = tp.env.get().str("project"); + if (project.len == 0) + return error.NoProject; + return send(.{ "delete_task", project, task }); +} + pub fn did_open(file_path: []const u8, file_type: *const FileType, version: usize, text: []const u8) (ProjectManagerError || ProjectError)!void { const project = tp.env.get().str("project"); if (project.len == 0) @@ -287,6 +308,7 @@ const Process = struct { var text_ptr: usize = 0; var text_len: usize = 0; var n: usize = 0; + var task: []const u8 = undefined; var root_dst: usize = 0; var root_src: usize = 0; @@ -325,6 +347,12 @@ const Process = struct { self.query_recent_files(from, project_directory, max, query) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "request_path_files", tp.extract(&project_directory), tp.extract(&max), tp.extract(&path) })) { self.request_path_files(from, project_directory, max, path) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "request_tasks", tp.extract(&project_directory) })) { + self.request_tasks(from, project_directory) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "add_task", tp.extract(&project_directory), tp.extract(&task) })) { + self.add_task(project_directory, task) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; + } else if (try cbor.match(m.buf, .{ "delete_task", tp.extract(&project_directory), tp.extract(&task) })) { + self.delete_task(project_directory, task) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; } else if (try cbor.match(m.buf, .{ "did_open", tp.extract(&project_directory), tp.extract(&path), tp.extract(&file_type), tp.extract_cbor(&language_server), tp.extract(&version), tp.extract(&text_ptr), tp.extract(&text_len) })) { const text = if (text_len > 0) @as([*]const u8, @ptrFromInt(text_ptr))[0..text_len] else ""; self.did_open(project_directory, path, file_type, language_server, version, text) catch |e| return from.forward_error(e, @errorReturnTrace()) catch error.ClientFailed; @@ -431,6 +459,21 @@ const Process = struct { try request_path_files_async(self.allocator, from, project, max, path); } + fn request_tasks(self: *Process, from: tp.pid_ref, project_directory: []const u8) (ProjectError || Project.ClientError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + try project.request_tasks(from); + } + + fn add_task(self: *Process, project_directory: []const u8, task: []const u8) (ProjectError || Project.ClientError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + try project.add_task(task); + } + + fn delete_task(self: *Process, project_directory: []const u8, task: []const u8) (ProjectError || Project.ClientError)!void { + const project = self.projects.get(project_directory) orelse return error.NoProject; + try project.delete_task(task); + } + fn did_open(self: *Process, project_directory: []const u8, file_path: []const u8, file_type: []const u8, language_server: []const u8, version: usize, text: []const u8) (ProjectError || Project.StartLspError || CallError || cbor.Error)!void { const frame = tracy.initZone(@src(), .{ .name = module_name ++ ".did_open" }); defer frame.deinit(); diff --git a/src/tui/mode/mini/buffer.zig b/src/tui/mode/mini/buffer.zig new file mode 100644 index 0000000..5f3f70c --- /dev/null +++ b/src/tui/mode/mini/buffer.zig @@ -0,0 +1,123 @@ +const std = @import("std"); +const tp = @import("thespian"); +const cbor = @import("cbor"); +const log = @import("log"); +const root = @import("root"); + +const input = @import("input"); +const keybind = @import("keybind"); +const command = @import("command"); +const EventHandler = @import("EventHandler"); + +const tui = @import("../../tui.zig"); + +pub fn Create(options: type) type { + return struct { + allocator: std.mem.Allocator, + input: std.ArrayList(u8), + commands: Commands = undefined, + + const Commands = command.Collection(cmds); + const Self = @This(); + + pub fn create(allocator: std.mem.Allocator, _: command.Context) !struct { tui.Mode, tui.MiniMode } { + const self: *Self = try allocator.create(Self); + self.* = .{ + .allocator = allocator, + .input = std.ArrayList(u8).init(allocator), + }; + try self.commands.init(self); + if (@hasDecl(options, "restore_state")) + options.restore_state(self) catch {}; + var mode = try keybind.mode("mini/buffer", allocator, .{ + .insert_command = "mini_mode_insert_bytes", + }); + mode.event_handler = EventHandler.to_owned(self); + return .{ mode, .{ .name = options.name(self) } }; + } + + pub fn deinit(self: *Self) void { + self.commands.deinit(); + self.input.deinit(); + self.allocator.destroy(self); + } + + pub fn receive(self: *Self, _: tp.pid_ref, m: tp.message) error{Exit}!bool { + var text: []const u8 = undefined; + + if (try m.match(.{ "system_clipboard", tp.extract(&text) })) { + self.input.appendSlice(text) catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + self.update_mini_mode_text(); + return false; + } + + fn message(comptime fmt: anytype, args: anytype) void { + var buf: [256]u8 = undefined; + tp.self_pid().send(.{ "message", std.fmt.bufPrint(&buf, fmt, args) catch @panic("too large") }) catch {}; + } + + fn update_mini_mode_text(self: *Self) void { + if (tui.mini_mode()) |mini_mode| { + mini_mode.text = self.input.items; + mini_mode.cursor = tui.egc_chunk_width(self.input.items, 0, 8); + } + } + + const cmds = struct { + pub const Target = Self; + const Ctx = command.Context; + const Result = command.Result; + + pub fn mini_mode_reset(self: *Self, _: Ctx) Result { + self.input.clearRetainingCapacity(); + self.update_mini_mode_text(); + } + pub const mini_mode_reset_meta = .{ .description = "Clear input" }; + + pub fn mini_mode_cancel(_: *Self, _: Ctx) Result { + command.executeName("exit_mini_mode", .{}) catch {}; + } + pub const mini_mode_cancel_meta = .{ .description = "Cancel input" }; + + pub fn mini_mode_delete_backwards(self: *Self, _: Ctx) Result { + if (self.input.items.len > 0) { + self.input.shrinkRetainingCapacity(self.input.items.len - tui.egc_last(self.input.items).len); + } + self.update_mini_mode_text(); + } + pub const mini_mode_delete_backwards_meta = .{ .description = "Delete backwards" }; + + pub fn mini_mode_insert_code_point(self: *Self, ctx: Ctx) Result { + var egc: u32 = 0; + if (!try ctx.args.match(.{tp.extract(&egc)})) + return error.InvalidMiniBufferInsertCodePointArgument; + var buf: [32]u8 = undefined; + const bytes = try input.ucs32_to_utf8(&[_]u32{egc}, &buf); + try self.input.appendSlice(buf[0..bytes]); + self.update_mini_mode_text(); + } + pub const mini_mode_insert_code_point_meta = .{ .arguments = &.{.integer} }; + + pub fn mini_mode_insert_bytes(self: *Self, ctx: Ctx) Result { + var bytes: []const u8 = undefined; + if (!try ctx.args.match(.{tp.extract(&bytes)})) + return error.InvalidMiniBufferInsertBytesArgument; + try self.input.appendSlice(bytes); + self.update_mini_mode_text(); + } + pub const mini_mode_insert_bytes_meta = .{ .arguments = &.{.string} }; + + pub fn mini_mode_select(self: *Self, _: Ctx) Result { + options.select(self); + self.update_mini_mode_text(); + } + pub const mini_mode_select_meta = .{ .description = "Select" }; + + pub fn mini_mode_paste(self: *Self, ctx: Ctx) Result { + return mini_mode_insert_bytes(self, ctx); + } + pub const mini_mode_paste_meta = .{ .arguments = &.{.string} }; + }; + }; +} diff --git a/src/tui/mode/overlay/buffer_palette.zig b/src/tui/mode/overlay/buffer_palette.zig index 638dd32..c8a5a0d 100644 --- a/src/tui/mode/overlay/buffer_palette.zig +++ b/src/tui/mode/overlay/buffer_palette.zig @@ -29,6 +29,10 @@ pub fn load_entries(palette: *Type) !usize { return if (palette.entries.items.len == 0) label.len else 2; } +pub fn clear_entries(palette: *Type) void { + palette.entries.clearRetainingCapacity(); +} + pub fn add_menu_entry(palette: *Type, entry: *Entry, matches: ?[]const usize) !void { var value = std.ArrayList(u8).init(palette.allocator); defer value.deinit(); diff --git a/src/tui/mode/overlay/palette.zig b/src/tui/mode/overlay/palette.zig index f2cc3d2..983f1bd 100644 --- a/src/tui/mode/overlay/palette.zig +++ b/src/tui/mode/overlay/palette.zig @@ -432,7 +432,7 @@ pub fn Create(options: type) type { const button = self.menu.get_selected() orelse return; const refresh = options.delete_item(self.menu, button); if (refresh) { - self.entries.clearRetainingCapacity(); + options.clear_entries(self); self.longest_hint = try options.load_entries(self); if (self.entries.items.len > 0) self.initial_selected = self.menu.selected; diff --git a/src/tui/mode/overlay/task_palette.zig b/src/tui/mode/overlay/task_palette.zig new file mode 100644 index 0000000..141cbd4 --- /dev/null +++ b/src/tui/mode/overlay/task_palette.zig @@ -0,0 +1,71 @@ +const std = @import("std"); +const cbor = @import("cbor"); +const tp = @import("thespian"); +const command = @import("command"); +const project_manager = @import("project_manager"); + +const tui = @import("../../tui.zig"); +pub const Type = @import("palette.zig").Create(@This()); +const module_name = @typeName(@This()); + +pub const label = "Run a task"; +pub const name = " task"; +pub const description = "task"; + +pub const Entry = struct { + label: []const u8, + hint: []const u8, +}; + +pub fn deinit(palette: *Type) void { + clear_entries(palette); +} + +pub fn load_entries(palette: *Type) !usize { + const rsp = try project_manager.request_tasks(palette.allocator); + defer palette.allocator.free(rsp.buf); + var iter: []const u8 = rsp.buf; + var len = try cbor.decodeArrayHeader(&iter); + while (len > 0) : (len -= 1) { + var task: []const u8 = undefined; + if (try cbor.matchValue(&iter, cbor.extract(&task))) { + (try palette.entries.addOne()).* = .{ .label = try palette.allocator.dupe(u8, task), .hint = "" }; + } else return error.InvalidTaskMessageField; + } + return if (palette.entries.items.len == 0) label.len else 1; +} + +pub fn clear_entries(palette: *Type) void { + for (palette.entries.items) |entry| + palette.allocator.free(entry.label); + palette.entries.clearRetainingCapacity(); +} + +pub fn add_menu_entry(palette: *Type, entry: *Entry, matches: ?[]const usize) !void { + var value = std.ArrayList(u8).init(palette.allocator); + defer value.deinit(); + const writer = value.writer(); + try cbor.writeValue(writer, entry.label); + try cbor.writeValue(writer, entry.hint); + try cbor.writeValue(writer, matches orelse &[_]usize{}); + try palette.menu.add_item_with_handler(value.items, select); + palette.items += 1; +} + +fn select(menu: **Type.MenuState, button: *Type.ButtonState) void { + var task: []const u8 = undefined; + var iter = button.opts.label; + if (!(cbor.matchString(&iter, &task) catch false)) return; + tp.self_pid().send(.{ "cmd", "exit_overlay_mode" }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + tp.self_pid().send(.{ "cmd", "add_task", .{task} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + tp.self_pid().send(.{ "cmd", "create_scratch_buffer", .{"*task*"} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + tp.self_pid().send(.{ "cmd", "shell_execute_insert", .{task} }) catch |e| menu.*.opts.ctx.logger.err(module_name, e); +} + +pub fn delete_item(menu: *Type.MenuState, button: *Type.ButtonState) bool { + var task: []const u8 = undefined; + var iter = button.opts.label; + if (!(cbor.matchString(&iter, &task) catch false)) return false; + command.executeName("delete_task", command.fmt(.{task})) catch |e| menu.*.opts.ctx.logger.err(module_name, e); + return true; //refresh list +} diff --git a/src/tui/tui.zig b/src/tui/tui.zig index d41144f..2caf0ac 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -818,6 +818,39 @@ const cmds = struct { } pub const switch_buffers_meta = .{ .description = "Switch buffers" }; + pub fn select_task(self: *Self, _: Ctx) Result { + return self.enter_overlay_mode(@import("mode/overlay/task_palette.zig").Type); + } + pub const select_task_meta = .{ .description = "Select a task to run" }; + + pub fn add_task(self: *Self, ctx: Ctx) Result { + return enter_mini_mode(self, struct { + pub const Type = @import("mode/mini/buffer.zig").Create(@This()); + pub const create = Type.create; + pub fn name(_: *Type) []const u8 { + return @import("mode/overlay/task_palette.zig").name; + } + pub fn select(self_: *Type) void { + project_manager.add_task(self_.input.items) catch |e| { + const logger = log.logger("tui"); + logger.err("add_task", e); + logger.deinit(); + }; + command.executeName("exit_mini_mode", .{}) catch {}; + command.executeName("select_task", .{}) catch {}; + } + }, ctx); + } + pub const add_task_meta = .{ .description = "Add a task to run" }; + + pub fn delete_task(_: *Self, ctx: Ctx) Result { + var task: []const u8 = undefined; + if (!try ctx.args.match(.{tp.extract(&task)})) + return error.InvalidDeleteTaskArgument; + project_manager.delete_task(task) catch |e| return tp.exit_error(e, @errorReturnTrace()); + } + pub const delete_task_meta = .{}; + pub fn change_theme(self: *Self, _: Ctx) Result { return self.enter_overlay_mode(@import("mode/overlay/theme_palette.zig").Type); }