diff --git a/src/buffer/Buffer.zig b/src/buffer/Buffer.zig index 110e9524..e08168c4 100644 --- a/src/buffer/Buffer.zig +++ b/src/buffer/Buffer.zig @@ -52,6 +52,7 @@ file_exists: bool = true, file_eol_mode: EolMode = .lf, last_save_eol_mode: EolMode = .lf, file_utf8_sanitized: bool = false, +detected_indent_size: ?usize = null, hidden: bool = false, ephemeral: bool = false, auto_save: bool = false, @@ -1395,9 +1396,47 @@ pub fn load(self: *const Self, reader: *std.Io.Reader, eol_mode: *EolMode, utf8_ leaves[cur_leaf] = .{ .leaf = .{ .buf = line, .bol = true, .eol = false } }; if (leaves.len != cur_leaf + 1) return error.Unexpected; + + self_.detected_indent_size = detect_indent_size(leaves[0..@min(leaves.len, 1000)]); + return Node.merge_in_place(leaves, self.allocator); } +fn detect_indent_size(leaves: []const Node) ?usize { + // frequency of each leading-space count (up to 16 spaces). + const max_spaces = 16; + var freq = std.mem.zeroes([max_spaces + 1]u32); + for (leaves) |leaf_node| { + const line = leaf_node.leaf.buf; + if (line.len == 0) continue; + if (line[0] == '\t') return 0; + var spaces: usize = 0; + for (line) |c| { + if (c == ' ') spaces += 1 else break; + } + if (spaces == 0 or spaces > max_spaces) continue; + freq[spaces] += 1; + } + + // find the 3 most frequently occurring indent levels + var top = [3]usize{ 0, 0, 0 }; + for (1..freq.len) |n| { + if (freq[n] > freq[top[2]]) { + top[2] = n; + if (freq[top[2]] > freq[top[1]]) std.mem.swap(usize, &top[1], &top[2]); + if (freq[top[1]] > freq[top[0]]) std.mem.swap(usize, &top[0], &top[1]); + } + } + + // GCD of the top indent levels gives the base indent unit + var gcd: usize = 0; + for (top) |n| { + if (n == 0) continue; + gcd = if (gcd == 0) n else std.math.gcd(gcd, n); + } + return if (gcd > 0) gcd else null; +} + pub fn load_from_string(self: *const Self, s: []const u8, eol_mode: *EolMode, utf8_sanitized: *bool) LoadError!Root { var reader = std.Io.Reader.fixed(s); return self.load(&reader, eol_mode, utf8_sanitized); @@ -1610,14 +1649,37 @@ pub fn store_to_existing_file_const(self: *const Self, file_path_: []const u8) S file_path = link; } - var atomic = blk: { - var write_buffer: [4096]u8 = undefined; - const stat = cwd().statFile(file_path) catch - break :blk try cwd().atomicFile(file_path, .{ .write_buffer = &write_buffer }); - break :blk try cwd().atomicFile(file_path, .{ .mode = stat.mode, .write_buffer = &write_buffer }); + var write_buffer: [4096]u8 = undefined; + + if (builtin.os.tag == .windows) { + // windows uses ACLs for ownership so we preserve mode only + var atomic = blk: { + const stat = cwd().statFile(file_path) catch + break :blk try cwd().atomicFile(file_path, .{ .write_buffer = &write_buffer }); + break :blk try cwd().atomicFile(file_path, .{ .mode = stat.mode, .write_buffer = &write_buffer }); + }; + defer atomic.deinit(); + try self.store_to_file_const(&atomic.file_writer.interface); + return atomic.finish(); + } + + // use fstat to get uid/gid, which std.fs.File.Stat omits. + const orig_stat: ?std.posix.Stat = blk: { + const f = cwd().openFile(file_path, .{}) catch break :blk null; + defer f.close(); + break :blk std.posix.fstat(f.handle) catch null; }; + const mode: std.fs.File.Mode = if (orig_stat) |s| s.mode else std.fs.File.default_mode; + var atomic = try cwd().atomicFile(file_path, .{ .mode = mode, .write_buffer = &write_buffer }); defer atomic.deinit(); try self.store_to_file_const(&atomic.file_writer.interface); + // fchmod bypasses the process umask preserving the exact original mode + // fchown restores original owner/group + // EPERM is silently ignored when we lack sufficient privileges + if (orig_stat) |s| { + atomic.file_writer.file.chmod(s.mode) catch {}; + std.posix.fchown(atomic.file_writer.file.handle, s.uid, s.gid) catch {}; + } try atomic.finish(); } diff --git a/src/buffer/unicode.zig b/src/buffer/unicode.zig index fc78be54..5a8d24d7 100644 --- a/src/buffer/unicode.zig +++ b/src/buffer/unicode.zig @@ -44,6 +44,7 @@ pub const char_pairs = [_]struct { []const u8, []const u8 }{ .{ "`", "`" }, .{ "(", ")" }, .{ "[", "]" }, + .{ "<", ">" }, .{ "{", "}" }, .{ "‘", "’" }, .{ "“", "”" }, @@ -56,6 +57,7 @@ pub const char_pairs = [_]struct { []const u8, []const u8 }{ pub const open_close_pairs = [_]struct { []const u8, []const u8 }{ .{ "(", ")" }, .{ "[", "]" }, + .{ "<", ">" }, .{ "{", "}" }, .{ "‘", "’" }, .{ "“", "”" }, diff --git a/src/config.zig b/src/config.zig index f3845cdb..0a8e3b2b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -7,6 +7,7 @@ input_mode: []const u8 = "flow", gutter_line_numbers_mode: ?LineNumberMode = null, gutter_line_numbers_style: DigitStyle = .ascii, gutter_symbols: bool = true, +gutter_diffs: bool = true, gutter_width_mode: GutterWidthMode = .local, gutter_width_minimum: usize = 4, gutter_width_maximum: usize = 8, diff --git a/src/keybind/builtin/vim.json b/src/keybind/builtin/vim.json index 8b3d9106..9849c546 100644 --- a/src/keybind/builtin/vim.json +++ b/src/keybind/builtin/vim.json @@ -86,6 +86,8 @@ ["di)", "cut_inside_parentheses"], ["di[", "cut_inside_square_brackets"], ["di]", "cut_inside_square_brackets"], + ["di", "cut_inside_angle_brackets"], + ["di", "cut_inside_angle_brackets"], ["di{", "cut_inside_braces"], ["di}", "cut_inside_braces"], ["di'", "cut_inside_single_quotes"], @@ -96,6 +98,8 @@ ["da)", "cut_around_parentheses"], ["da[", "cut_around_square_brackets"], ["da]", "cut_around_square_brackets"], + ["da", "cut_around_angle_brackets"], + ["da", "cut_around_angle_brackets"], ["da{", "cut_around_braces"], ["da}", "cut_around_braces"], ["da'", "cut_around_single_quotes"], @@ -112,6 +116,8 @@ ["ci)", ["enter_mode", "insert"], ["cut_inside_parentheses"]], ["ci[", ["enter_mode", "insert"], ["cut_inside_square_brackets"]], ["ci]", ["enter_mode", "insert"], ["cut_inside_square_brackets"]], + ["ci", ["enter_mode", "insert"], ["cut_inside_angle_brackets"]], + ["ci", ["enter_mode", "insert"], ["cut_inside_angle_brackets"]], ["ci{", ["enter_mode", "insert"], ["cut_inside_braces"]], ["ci}", ["enter_mode", "insert"], ["cut_inside_braces"]], ["ci'", ["enter_mode", "insert"], ["cut_inside_single_quotes"]], @@ -122,6 +128,8 @@ ["ca)", ["enter_mode", "insert"], ["cut_around_parentheses"]], ["ca[", ["enter_mode", "insert"], ["cut_around_square_brackets"]], ["ca]", ["enter_mode", "insert"], ["cut_around_square_brackets"]], + ["ca", ["enter_mode", "insert"], ["cut_around_angle_brackets"]], + ["ca", ["enter_mode", "insert"], ["cut_around_angle_brackets"]], ["ca{", ["enter_mode", "insert"], ["cut_around_braces"]], ["ca}", ["enter_mode", "insert"], ["cut_around_braces"]], ["ca'", ["enter_mode", "insert"], ["cut_around_single_quotes"]], @@ -134,6 +142,8 @@ ["yi)", ["copy_inside_parentheses"], ["cancel"]], ["yi[", ["copy_inside_square_brackets"], ["cancel"]], ["yi]", ["copy_inside_square_brackets"], ["cancel"]], + ["yi", ["copy_inside_angle_brackets"], ["cancel"]], + ["yi", ["copy_inside_angle_brackets"], ["cancel"]], ["yi{", ["copy_inside_braces"], ["cancel"]], ["yi}", ["copy_inside_braces"], ["cancel"]], ["yi'", ["copy_inside_single_quotes"], ["cancel"]], @@ -144,6 +154,8 @@ ["ya)", ["copy_around_parentheses"], ["cancel"]], ["ya[", ["copy_around_square_brackets"], ["cancel"]], ["ya]", ["copy_around_square_brackets"], ["cancel"]], + ["ya", ["copy_around_angle_brackets"], ["cancel"]], + ["ya", ["copy_around_angle_brackets"], ["cancel"]], ["ya{", ["copy_around_braces"], ["cancel"]], ["ya}", ["copy_around_braces"], ["cancel"]], ["ya'", ["copy_around_single_quotes"], ["cancel"]], @@ -224,6 +236,8 @@ ["i)", "select_inside_parentheses"], ["i[", "select_inside_square_brackets"], ["i]", "select_inside_square_brackets"], + ["i", "select_inside_angle_brackets"], + ["i", "select_inside_angle_brackets"], ["i{", "select_inside_braces"], ["i}", "select_inside_braces"], ["i'", "select_inside_single_quotes"], @@ -234,6 +248,8 @@ ["a)", "select_around_parentheses"], ["a[", "select_around_square_brackets"], ["a]", "select_around_square_brackets"], + ["a", "select_around_angle_brackets"], + ["a", "select_around_angle_brackets"], ["a{", "select_around_braces"], ["a}", "select_around_braces"], ["a'", "select_around_single_quotes"], diff --git a/src/tui/editor.zig b/src/tui/editor.zig index d09c311a..2c52b859 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -831,6 +831,13 @@ pub const Editor = struct { } fn detect_indent_mode(self: *Self, content: []const u8) void { + if (self.buffer) |buf| { + if (buf.detected_indent_size) |detected_indent_size| { + self.indent_size = detected_indent_size; + self.indent_mode = .spaces; + return; + } + } var it = std.mem.splitScalar(u8, content, '\n'); while (it.next()) |line| { if (line.len == 0) continue; @@ -842,7 +849,6 @@ pub const Editor = struct { } self.indent_size = tui.config().indent_size; self.indent_mode = .spaces; - return; } fn refresh_tab_width(self: *Self) void { @@ -4232,11 +4238,25 @@ pub const Editor = struct { fn pull_cursel_up(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { var root = root_; const saved = cursel.*; + errdefer cursel.* = saved; const sel = cursel.expand_selection_to_line(root, self.metrics); var sfa = std.heap.stackFallback(4096, self.allocator); const sfa_allocator = sfa.get(); - const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; - defer sfa_allocator.free(cut_text); + const cut_text_raw = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; + defer sfa_allocator.free(cut_text_raw); + + const is_last_no_nl = sel.end.row == cursel.cursor.row; + + var cut_text_buf: ?[]u8 = null; + defer if (cut_text_buf) |t| sfa_allocator.free(t); + const cut_text: []const u8 = if (is_last_no_nl) blk: { + const buf = sfa_allocator.alloc(u8, cut_text_raw.len + 1) catch return error.Stop; + cut_text_buf = buf; + @memcpy(buf[0..cut_text_raw.len], cut_text_raw); + buf[cut_text_raw.len] = '\n'; + break :blk buf; + } else cut_text_raw; + root = try self.delete_selection(root, cursel, allocator); try cursel.cursor.move_up(root, self.metrics); root = self.insert(root, cursel, cut_text, allocator) catch return error.Stop; @@ -4246,6 +4266,17 @@ pub const Editor = struct { try sel_.begin.move_up(root, self.metrics); try sel_.end.move_up(root, self.metrics); } + + if (is_last_no_nl) { + const last_content_row = root.lines() - 2; + var del_begin: Cursor = .{ .row = last_content_row, .col = 0 }; + del_begin.move_end(root, self.metrics); + var tmp: CurSel = .{ + .cursor = del_begin, + .selection = .{ .begin = del_begin, .end = .{ .row = last_content_row + 1, .col = 0 } }, + }; + root = try self.delete_selection(root, &tmp, allocator); + } return root; } @@ -4260,14 +4291,35 @@ pub const Editor = struct { fn pull_cursel_down(self: *Self, root_: Buffer.Root, cursel: *CurSel, allocator: Allocator) error{Stop}!Buffer.Root { var root = root_; const saved = cursel.*; + errdefer cursel.* = saved; + const cursor_row_before = cursel.cursor.row; + const lines_before = root.lines(); const sel = cursel.expand_selection_to_line(root, self.metrics); var sfa = std.heap.stackFallback(4096, self.allocator); const sfa_allocator = sfa.get(); - const cut_text = copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; + const cut_text = if (sel.empty()) + &.{} + else + copy_selection(root, sel.*, sfa_allocator, self.metrics) catch return error.Stop; defer sfa_allocator.free(cut_text); root = try self.delete_selection(root, cursel, allocator); - try cursel.cursor.move_down(root, self.metrics); - root = self.insert(root, cursel, cut_text, allocator) catch return error.Stop; + const moved_down = blk: { + cursel.cursor.move_down(root, self.metrics) catch break :blk false; + break :blk true; + }; + if (moved_down) { + root = self.insert(root, cursel, cut_text, allocator) catch return error.Stop; + } else { + if (cursor_row_before >= lines_before - 1) return error.Stop; + cursel.cursor.move_end(root, self.metrics); + root = self.insert(root, cursel, "\n", allocator) catch return error.Stop; + const cut_no_nl = if (std.mem.endsWith(u8, cut_text, "\n")) + cut_text[0 .. cut_text.len - 1] + else + cut_text; + if (cut_no_nl.len > 0) + root = self.insert(root, cursel, cut_no_nl, allocator) catch return error.Stop; + } cursel.* = saved; try cursel.cursor.move_down(root, self.metrics); if (cursel.selection) |*sel_| { diff --git a/src/tui/editor_gutter.zig b/src/tui/editor_gutter.zig index 7f2e2583..c7c3840f 100644 --- a/src/tui/editor_gutter.zig +++ b/src/tui/editor_gutter.zig @@ -262,6 +262,7 @@ inline fn render_line_highlight(self: *Self, pos: usize, theme: *const Widget.Th } inline fn render_diff_symbols(self: *Self, diff_symbols: *[]Diff, pos: usize, linenum_: usize, theme: *const Widget.Theme) void { + if (!tui.config().gutter_diffs) return; const linenum = linenum_ - 1; if (diff_symbols.len == 0) return; while ((diff_symbols.*)[0].line < linenum) { diff --git a/src/tui/filelist_view.zig b/src/tui/filelist_view.zig index f597885a..6ab0417d 100644 --- a/src/tui/filelist_view.zig +++ b/src/tui/filelist_view.zig @@ -197,7 +197,34 @@ fn handle_render_menu(self: *Self, button: *ButtonType, theme: *const Widget.The .Warning => button.plane.set_style(style_warning), .Error => button.plane.set_style(style_error), } - _ = button.plane.print("{f}", .{std.ascii.hexEscape(entry.lines, .lower)}) catch {}; + const tab_width = tui.config().tab_width; + const show_tabs_visual = switch (tui.config().whitespace_mode) { + .tabs, .external, .visible, .full => true, + else => false, + }; + var codepoints = (std.unicode.Utf8View.init(entry.lines) catch std.unicode.Utf8View.initUnchecked(entry.lines)).iterator(); + while (codepoints.nextCodepointSlice()) |codepoint| { + const cp = std.unicode.utf8Decode(codepoint) catch { + for (codepoint) |b| _ = button.plane.print("\\x{x:0>2}", .{b}) catch {}; + continue; + }; + switch (cp) { + '\t' => { + const col: usize = @intCast(button.plane.cursor_x()); + const spaces = tab_width - (col % tab_width); + if (show_tabs_visual) { + button.plane.set_style(.{ .fg = theme.editor_whitespace.fg, .bg = style_label.bg }); + for (0..spaces) |i| + _ = button.plane.putstr(if (i < spaces - 1) editor.whitespace.char.tab_begin else editor.whitespace.char.tab_end) catch {}; + button.plane.set_style(style_label); + } else { + for (0..spaces) |_| _ = button.plane.putstr(" ") catch {}; + } + }, + 0x00...0x08, 0x0a...0x1f, 0x7f => _ = button.plane.print("\\x{x:0>2}", .{cp}) catch {}, + else => _ = button.plane.putstr(codepoint) catch {}, + } + } return false; } diff --git a/src/tui/mainview.zig b/src/tui/mainview.zig index ab5220a7..a0a388bc 100644 --- a/src/tui/mainview.zig +++ b/src/tui/mainview.zig @@ -1165,6 +1165,13 @@ const cmds = struct { } pub const toggle_inline_diagnostics_meta: Meta = .{ .description = "Toggle display of diagnostics inline" }; + pub fn toggle_gutter_diffs(_: *Self, _: Ctx) Result { + const config = tui.config_mut(); + config.gutter_diffs = !config.gutter_diffs; + try tui.save_config(); + } + pub const toggle_gutter_diffs_meta: Meta = .{ .description = "Toggle gutter diff markers" }; + pub fn goto_next_file_or_diagnostic(self: *Self, ctx: Ctx) Result { if (self.is_panel_view_showing(filelist_view)) { switch (self.file_list_type) { diff --git a/src/tui/mode/vim.zig b/src/tui/mode/vim.zig index 823cc812..dc1646a4 100644 --- a/src/tui/mode/vim.zig +++ b/src/tui/mode/vim.zig @@ -202,6 +202,24 @@ const cmds_ = struct { } pub const select_around_square_brackets_meta: Meta = .{ .description = "Select around []" }; + pub fn select_inside_angle_brackets(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_angle_brackets_textobject, ed.metrics); + } + pub const select_inside_angle_brackets_meta: Meta = .{ .description = "Select inside <>" }; + + pub fn select_around_angle_brackets(_: *void, _: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_angle_brackets_textobject, ed.metrics); + } + pub const select_around_angle_brackets_meta: Meta = .{ .description = "Select around <>" }; + pub fn select_inside_braces(_: *void, _: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -316,6 +334,26 @@ const cmds_ = struct { } pub const cut_around_square_brackets_meta: Meta = .{ .description = "Cut around []" }; + pub fn cut_inside_angle_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_angle_brackets_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_inside_angle_brackets_meta: Meta = .{ .description = "Cut inside <>" }; + + pub fn cut_around_angle_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_angle_brackets_textobject, ed.metrics); + try ed.cut_internal_vim(ctx); + } + pub const cut_around_angle_brackets_meta: Meta = .{ .description = "Cut around <>" }; + pub fn cut_inside_braces(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -436,6 +474,26 @@ const cmds_ = struct { } pub const copy_around_square_brackets_meta: Meta = .{ .description = "Copy around []" }; + pub fn copy_inside_angle_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_inside_angle_brackets_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_inside_angle_brackets_meta: Meta = .{ .description = "Copy inside <>" }; + + pub fn copy_around_angle_brackets(_: *void, ctx: Ctx) Result { + const mv = tui.mainview() orelse return; + const ed = mv.get_active_editor() orelse return; + const root = ed.buf_root() catch return; + + try ed.with_cursels_const(root, select_around_angle_brackets_textobject, ed.metrics); + try ed.copy_internal_vim(ctx); + } + pub const copy_around_angle_brackets_meta: Meta = .{ .description = "Copy around <>" }; + pub fn copy_inside_braces(_: *void, ctx: Ctx) Result { const mv = tui.mainview() orelse return; const ed = mv.get_active_editor() orelse return; @@ -575,6 +633,14 @@ fn select_around_square_brackets_textobject(root: Buffer.Root, cursel: *CurSel, return try select_scope_textobject(root, cursel, metrics, "[", "]", .around); } +fn select_inside_angle_brackets_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_scope_textobject(root, cursel, metrics, "<", ">", .inside); +} + +fn select_around_angle_brackets_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { + return try select_scope_textobject(root, cursel, metrics, "<", ">", .around); +} + fn select_inside_braces_textobject(root: Buffer.Root, cursel: *CurSel, metrics: Buffer.Metrics) !void { return try select_scope_textobject(root, cursel, metrics, "{", "}", .inside); } diff --git a/test/tests_buffer.zig b/test/tests_buffer.zig index 18b0c58f..28ed4918 100644 --- a/test/tests_buffer.zig +++ b/test/tests_buffer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const Buffer = @import("Buffer"); const ArrayList = std.ArrayList; @@ -99,6 +100,39 @@ test "buffer.store_to_file_and_clean" { try std.testing.expectEqualStrings(input, output); } +test "buffer.store_to_file_and_clean preserves file mode" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + + const tmp_path = "test/tmp_mode_test.txt"; + { + const f = try std.fs.cwd().createFile(tmp_path, .{}); + defer f.close(); + try f.writeAll("hello\n"); + try f.chmod(0o644); + } + defer std.fs.cwd().deleteFile(tmp_path) catch {}; + + // Set a umask that would strip group/other read bits (0o644 -> 0o600 without the fix) + // to verify that fchmod bypasses the process umask on save. + const prev_umask = if (comptime builtin.os.tag == .linux) + std.os.linux.syscall1(.umask, 0o077) + else + @as(usize, 0); + defer if (comptime builtin.os.tag == .linux) { + _ = std.os.linux.syscall1(.umask, prev_umask); + }; + + const buffer = try Buffer.create(a); + defer buffer.deinit(); + try buffer.load_from_file_and_update(tmp_path); + try buffer.store_to_file_and_clean(tmp_path); + + const f = try std.fs.cwd().openFile(tmp_path, .{}); + defer f.close(); + const stat = try std.posix.fstat(f.handle); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o644), stat.mode & 0o777); +} + fn get_line(buf: *const Buffer, line: usize) ![]const u8 { var result: std.Io.Writer.Allocating = .init(a); try buf.root.get_line(line, &result.writer, metrics());