Fix local editor raw mode and chrome
This commit is contained in:
+41
-4
@@ -235,6 +235,11 @@ fn runLocalEditor(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.enterInsertMode();
|
||||||
|
|
||||||
|
var raw_terminal = try RawTerminal.enable(stdin_file.handle);
|
||||||
|
defer raw_terminal.restore();
|
||||||
|
|
||||||
var stdin_buffer: [4096]u8 = undefined;
|
var stdin_buffer: [4096]u8 = undefined;
|
||||||
var stdin_reader = stdin_file.readerStreaming(io, &stdin_buffer);
|
var stdin_reader = stdin_file.readerStreaming(io, &stdin_buffer);
|
||||||
const stdin = &stdin_reader.interface;
|
const stdin = &stdin_reader.interface;
|
||||||
@@ -298,6 +303,35 @@ fn persistLocalSave(
|
|||||||
last_client_saved.* = client_copy;
|
last_client_saved.* = client_copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RawTerminal = struct {
|
||||||
|
fd: std.posix.fd_t,
|
||||||
|
original: std.posix.termios,
|
||||||
|
|
||||||
|
fn enable(fd: std.posix.fd_t) !RawTerminal {
|
||||||
|
const original = try std.posix.tcgetattr(fd);
|
||||||
|
var raw = original;
|
||||||
|
raw.iflag.BRKINT = false;
|
||||||
|
raw.iflag.ICRNL = false;
|
||||||
|
raw.iflag.INPCK = false;
|
||||||
|
raw.iflag.ISTRIP = false;
|
||||||
|
raw.iflag.IXON = false;
|
||||||
|
raw.oflag.OPOST = false;
|
||||||
|
raw.cflag.CSIZE = .CS8;
|
||||||
|
raw.lflag.ECHO = false;
|
||||||
|
raw.lflag.ICANON = false;
|
||||||
|
raw.lflag.IEXTEN = false;
|
||||||
|
raw.lflag.ISIG = false;
|
||||||
|
raw.cc[@intFromEnum(std.posix.V.MIN)] = 1;
|
||||||
|
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
|
||||||
|
try std.posix.tcsetattr(fd, .FLUSH, raw);
|
||||||
|
return .{ .fd = fd, .original = original };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(self: *RawTerminal) void {
|
||||||
|
std.posix.tcsetattr(self.fd, .FLUSH, self.original) catch {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[]u8 {
|
fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[]u8 {
|
||||||
const first = stdin.takeByte() catch |err| switch (err) {
|
const first = stdin.takeByte() catch |err| switch (err) {
|
||||||
error.EndOfStream => return null,
|
error.EndOfStream => return null,
|
||||||
@@ -337,7 +371,7 @@ fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.Fil
|
|||||||
try stdout.writeStreamingAll(io, if (is_dir)
|
try stdout.writeStreamingAll(io, if (is_dir)
|
||||||
"\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n"
|
"\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n"
|
||||||
else
|
else
|
||||||
"\nEditor. Type to insert; arrows move; Space shows commands; Space w saves; Space q quits when clean.\n");
|
"\nEditor starts in insert mode. Esc or Space n enters normal mode; Space commands work in normal mode; Space w 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 {
|
||||||
@@ -514,12 +548,15 @@ test "regular: local editor client exposes save and dirty quit guards" {
|
|||||||
try std.testing.expectEqualStrings("hello", edited);
|
try std.testing.expectEqualStrings("hello", edited);
|
||||||
|
|
||||||
try client.handleInput(" ");
|
try client.handleInput(" ");
|
||||||
try std.testing.expectEqualStrings("insert_space", client.pendingRailName());
|
try client.handleInput("x");
|
||||||
try client.handleInput("n");
|
const with_space = try client.snapshotBytesAlloc(std.testing.allocator);
|
||||||
|
defer std.testing.allocator.free(with_space);
|
||||||
|
try std.testing.expectEqualStrings("hello x", with_space);
|
||||||
|
try client.handleTraceLine("key escape");
|
||||||
try std.testing.expectEqualStrings("normal", client.modeName());
|
try std.testing.expectEqualStrings("normal", client.modeName());
|
||||||
try client.handleInput(" ");
|
try client.handleInput(" ");
|
||||||
try client.handleInput("w");
|
try client.handleInput("w");
|
||||||
try std.testing.expectEqualStrings("hello", try client.saved());
|
try std.testing.expectEqualStrings("hello x", try client.saved());
|
||||||
|
|
||||||
try client.handleInput("i");
|
try client.handleInput("i");
|
||||||
try client.handleInput("!");
|
try client.handleInput("!");
|
||||||
|
|||||||
+208
-19
@@ -319,23 +319,29 @@ pub const Client = struct {
|
|||||||
const max_body_lines = self.viewport.height - 1;
|
const max_body_lines = self.viewport.height - 1;
|
||||||
const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte);
|
const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte);
|
||||||
const cursor_col = columnAt(snap.bytes, snap.cursor_byte);
|
const cursor_col = columnAt(snap.bytes, snap.cursor_byte);
|
||||||
|
const total_lines = countDocumentLines(snap.bytes);
|
||||||
|
const gutter_digits = @max(@as(usize, 2), decimalDigits(total_lines));
|
||||||
|
const gutter_width = @min(self.viewport.width, gutter_digits + 2);
|
||||||
|
const content_width = if (self.viewport.width > gutter_width) self.viewport.width - gutter_width else 0;
|
||||||
var visible_line_index: usize = 0;
|
var visible_line_index: usize = 0;
|
||||||
var body_lines_used: usize = 0;
|
var body_lines_used: usize = 0;
|
||||||
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
|
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
|
||||||
while (line_iter.next()) |line| : (visible_line_index += 1) {
|
while (line_iter.next()) |line| : (visible_line_index += 1) {
|
||||||
if (body_lines_used >= max_body_lines) break;
|
if (body_lines_used >= max_body_lines) break;
|
||||||
try appendVisibleCells(allocator, &out, line, self.viewport.width);
|
try appendEditorLine(
|
||||||
try out.append(allocator, '\n');
|
allocator,
|
||||||
|
&out,
|
||||||
|
line,
|
||||||
|
visible_line_index + 1,
|
||||||
|
gutter_digits,
|
||||||
|
content_width,
|
||||||
|
visible_line_index == cursor_line,
|
||||||
|
cursor_col,
|
||||||
|
);
|
||||||
body_lines_used += 1;
|
body_lines_used += 1;
|
||||||
|
|
||||||
if (visible_line_index == cursor_line and body_lines_used < max_body_lines) {
|
|
||||||
try appendCursorLine(allocator, &out, @min(cursor_col, self.viewport.width - 1), self.viewport.width);
|
|
||||||
body_lines_used += 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
|
while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
|
||||||
try out.append(allocator, '~');
|
try appendVirtualLine(allocator, &out, gutter_digits, content_width);
|
||||||
try out.append(allocator, '\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const leader_status = self.leader.status();
|
const leader_status = self.leader.status();
|
||||||
@@ -360,7 +366,7 @@ pub const Client = struct {
|
|||||||
.{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
|
.{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
|
||||||
);
|
);
|
||||||
defer allocator.free(status);
|
defer allocator.free(status);
|
||||||
try appendVisibleCells(allocator, &out, status, self.viewport.width);
|
try appendStatusLine(allocator, &out, status, self.viewport.width);
|
||||||
return out.toOwnedSlice(allocator);
|
return out.toOwnedSlice(allocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +451,11 @@ pub const Client = struct {
|
|||||||
return @tagName(self.effectiveMode());
|
return @tagName(self.effectiveMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn enterInsertMode(self: *Client) void {
|
||||||
|
self.mode = .insert;
|
||||||
|
self.message = "insert";
|
||||||
|
}
|
||||||
|
|
||||||
pub fn pendingRailName(self: *const Client) []const u8 {
|
pub fn pendingRailName(self: *const Client) []const u8 {
|
||||||
return @tagName(self.prefix);
|
return @tagName(self.prefix);
|
||||||
}
|
}
|
||||||
@@ -2086,9 +2097,14 @@ pub const Client = struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insertText(self: *Client, text: []const u8) !void {
|
fn insertText(self: *Client, text: []const u8) !void {
|
||||||
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text});
|
try self.recordUndo();
|
||||||
defer self.allocator.free(line);
|
self.clearRedo();
|
||||||
try self.applyMutatingProtocolCommand(line);
|
self.session.dispatch(.{ .insert = text }) catch |err| {
|
||||||
|
self.dropLastUndoSnapshot();
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
self.noteDocumentChanged();
|
||||||
|
self.message = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void {
|
fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void {
|
||||||
@@ -2295,6 +2311,97 @@ pub const Client = struct {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ansi_reset = "\x1b[0m";
|
||||||
|
const ansi_gutter = "\x1b[38;2;88;96;112m";
|
||||||
|
const ansi_virtual = "\x1b[38;2;64;70;86m";
|
||||||
|
const ansi_text = "\x1b[38;2;214;222;235m";
|
||||||
|
const ansi_current_line = "\x1b[48;2;26;31;43m";
|
||||||
|
const ansi_cursor = "\x1b[38;2;18;22;30;48;2;245;197;92m";
|
||||||
|
const ansi_status = "\x1b[38;2;18;22;30;48;2;126;231;135m";
|
||||||
|
|
||||||
|
fn appendEditorLine(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
out: *std.ArrayList(u8),
|
||||||
|
line: []const u8,
|
||||||
|
line_no: usize,
|
||||||
|
gutter_digits: usize,
|
||||||
|
content_width: usize,
|
||||||
|
is_cursor_line: bool,
|
||||||
|
cursor_col: usize,
|
||||||
|
) !void {
|
||||||
|
try appendLineNumber(allocator, out, line_no, gutter_digits);
|
||||||
|
if (content_width == 0) {
|
||||||
|
try out.append(allocator, '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line);
|
||||||
|
try out.appendSlice(allocator, ansi_text);
|
||||||
|
try appendEditorCells(allocator, out, line, content_width, is_cursor_line, cursor_col);
|
||||||
|
try out.appendSlice(allocator, ansi_reset);
|
||||||
|
try out.append(allocator, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appendLineNumber(allocator: std.mem.Allocator, out: *std.ArrayList(u8), line_no: usize, gutter_digits: usize) !void {
|
||||||
|
const text = try std.fmt.allocPrint(allocator, "{d}", .{line_no});
|
||||||
|
defer allocator.free(text);
|
||||||
|
try out.appendSlice(allocator, ansi_gutter);
|
||||||
|
var pad: usize = text.len;
|
||||||
|
while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' ');
|
||||||
|
try out.appendSlice(allocator, text);
|
||||||
|
try out.appendSlice(allocator, "│");
|
||||||
|
try out.appendSlice(allocator, ansi_reset);
|
||||||
|
try out.append(allocator, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appendVirtualLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), gutter_digits: usize, content_width: usize) !void {
|
||||||
|
_ = content_width;
|
||||||
|
try out.appendSlice(allocator, ansi_gutter);
|
||||||
|
var pad: usize = 0;
|
||||||
|
while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' ');
|
||||||
|
try out.appendSlice(allocator, "│");
|
||||||
|
try out.appendSlice(allocator, ansi_virtual);
|
||||||
|
try out.appendSlice(allocator, " ·");
|
||||||
|
try out.appendSlice(allocator, ansi_reset);
|
||||||
|
try out.append(allocator, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appendEditorCells(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
out: *std.ArrayList(u8),
|
||||||
|
bytes: []const u8,
|
||||||
|
max_cells: usize,
|
||||||
|
is_cursor_line: bool,
|
||||||
|
cursor_col: usize,
|
||||||
|
) !void {
|
||||||
|
_ = cursor_col;
|
||||||
|
var remaining = max_cells;
|
||||||
|
if (is_cursor_line and remaining > 0) {
|
||||||
|
try out.appendSlice(allocator, ansi_cursor);
|
||||||
|
try out.appendSlice(allocator, "▌");
|
||||||
|
try out.appendSlice(allocator, ansi_reset);
|
||||||
|
try out.appendSlice(allocator, ansi_current_line);
|
||||||
|
try out.appendSlice(allocator, ansi_text);
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
var i: usize = 0;
|
||||||
|
var col: usize = 0;
|
||||||
|
while (i < bytes.len and col < remaining) {
|
||||||
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
|
||||||
|
const end = @min(bytes.len, i + len);
|
||||||
|
const width = @max(@as(usize, 1), session_mod.cellWidth(bytes[i..end]));
|
||||||
|
if (col + width > remaining) break;
|
||||||
|
try out.appendSlice(allocator, bytes[i..end]);
|
||||||
|
col += width;
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn appendStatusLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), status: []const u8, width: usize) !void {
|
||||||
|
try out.appendSlice(allocator, ansi_status);
|
||||||
|
try appendVisibleCells(allocator, out, status, width);
|
||||||
|
try out.appendSlice(allocator, ansi_reset);
|
||||||
|
}
|
||||||
|
|
||||||
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
|
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < bytes.len) {
|
while (i < bytes.len) {
|
||||||
@@ -2307,11 +2414,37 @@ fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), byt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn appendCursorLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), cursor_col: usize, max_cells: usize) !void {
|
fn countDocumentLines(bytes: []const u8) usize {
|
||||||
|
var lines: usize = 1;
|
||||||
|
for (bytes) |byte| {
|
||||||
|
if (byte == '\n') lines += 1;
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decimalDigits(value: usize) usize {
|
||||||
|
var digits: usize = 1;
|
||||||
|
var n = value;
|
||||||
|
while (n >= 10) : (digits += 1) n /= 10;
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visibleCellWidth(bytes: []const u8) usize {
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < cursor_col and i + 1 < max_cells) : (i += 1) try out.append(allocator, ' ');
|
var cells: usize = 0;
|
||||||
try out.append(allocator, '^');
|
while (i < bytes.len) {
|
||||||
try out.append(allocator, '\n');
|
if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '[') {
|
||||||
|
i += 2;
|
||||||
|
while (i < bytes.len and bytes[i] != 'm') : (i += 1) {}
|
||||||
|
if (i < bytes.len) i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
|
||||||
|
const end = @min(bytes.len, i + len);
|
||||||
|
cells += session_mod.cellWidth(bytes[i..end]);
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize {
|
fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize {
|
||||||
@@ -2340,7 +2473,7 @@ fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics {
|
|||||||
var lines = std.mem.splitScalar(u8, frame, '\n');
|
var lines = std.mem.splitScalar(u8, frame, '\n');
|
||||||
while (lines.next()) |line| {
|
while (lines.next()) |line| {
|
||||||
diagnostics.frame_lines += 1;
|
diagnostics.frame_lines += 1;
|
||||||
diagnostics.max_line_cells = @max(diagnostics.max_line_cells, session_mod.cellWidth(line));
|
diagnostics.max_line_cells = @max(diagnostics.max_line_cells, visibleCellWidth(line));
|
||||||
}
|
}
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
}
|
}
|
||||||
@@ -2377,7 +2510,7 @@ fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnosti
|
|||||||
fn assertLinesFit(frame: []const u8, width: usize) !void {
|
fn assertLinesFit(frame: []const u8, width: usize) !void {
|
||||||
var lines = std.mem.splitScalar(u8, frame, '\n');
|
var lines = std.mem.splitScalar(u8, frame, '\n');
|
||||||
while (lines.next()) |line| {
|
while (lines.next()) |line| {
|
||||||
try std.testing.expect(session_mod.cellWidth(line) <= width);
|
try std.testing.expect(visibleCellWidth(line) <= width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4712,3 +4845,59 @@ test "adversarial: unknown physical key names stay rejected and leader help stay
|
|||||||
defer std.testing.allocator.free(frame);
|
defer std.testing.allocator.free(frame);
|
||||||
try assertLinesFit(frame, 72);
|
try assertLinesFit(frame, 72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: editor render has colorscheme gutter cursor and status chrome" {
|
||||||
|
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
|
||||||
|
defer client.deinit();
|
||||||
|
try client.handleTraceLine("open alpha\nbeta");
|
||||||
|
const frame = try client.render(std.testing.allocator);
|
||||||
|
defer std.testing.allocator.free(frame);
|
||||||
|
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, " 2│") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;18;22;30;48;2;126;231;135m") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "^\n") == null);
|
||||||
|
try assertLinesFit(frame, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: empty editor still shows first line gutter and cursor marker" {
|
||||||
|
var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 5 });
|
||||||
|
defer client.deinit();
|
||||||
|
const frame = try client.render(std.testing.allocator);
|
||||||
|
defer std.testing.allocator.free(frame);
|
||||||
|
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, " ·") != null);
|
||||||
|
try assertLinesFit(frame, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: local launcher insert mode lets ordinary text with spaces insert" {
|
||||||
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
|
||||||
|
defer client.deinit();
|
||||||
|
try client.handleTraceLine("open ");
|
||||||
|
client.enterInsertMode();
|
||||||
|
for ("what is up") |byte| {
|
||||||
|
const key_bytes = [_]u8{byte};
|
||||||
|
try client.handleInput(&key_bytes);
|
||||||
|
}
|
||||||
|
const snap = try client.session.snapshot();
|
||||||
|
try std.testing.expectEqualStrings("what is up", snap.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: normal mode Space leader still works after insert escape" {
|
||||||
|
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
|
||||||
|
defer client.deinit();
|
||||||
|
try client.handleTraceLine("open ");
|
||||||
|
client.enterInsertMode();
|
||||||
|
for ("abc") |byte| {
|
||||||
|
const key_bytes = [_]u8{byte};
|
||||||
|
try client.handleInput(&key_bytes);
|
||||||
|
}
|
||||||
|
try client.handleTraceLine("key escape");
|
||||||
|
try client.handleInput(" ");
|
||||||
|
try client.handleInput("w");
|
||||||
|
try std.testing.expectEqualStrings("abc", try client.saved());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user