mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Improve TUI pager and search ergonomics
This commit is contained in:
parent
1c451b7722
commit
797ecbb992
9 changed files with 380 additions and 89 deletions
|
|
@ -28,6 +28,8 @@
|
||||||
- Added a VHS tape draft for recording the core TNT terminal-chat experience.
|
- Added a VHS tape draft for recording the core TNT terminal-chat experience.
|
||||||
- Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and
|
- Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and
|
||||||
an open inbox refreshes when a new private message arrives.
|
an open inbox refreshes when a new private message arrives.
|
||||||
|
- Added `/` in NORMAL mode as a fast history-search entrypoint backed by the
|
||||||
|
existing `:search` command.
|
||||||
- Added `make slow-client-test`, an opt-in regression for an unread
|
- Added `make slow-client-test`, an opt-in regression for an unread
|
||||||
interactive SSH client under backpressure while health, stats, post, tail,
|
interactive SSH client under backpressure while health, stats, post, tail,
|
||||||
and server survival stay responsive.
|
and server survival stay responsive.
|
||||||
|
|
@ -73,6 +75,12 @@
|
||||||
contract.
|
contract.
|
||||||
- The two-user lifecycle test now covers opening `:inbox` before a private
|
- The two-user lifecycle test now covers opening `:inbox` before a private
|
||||||
message arrives, matching the way users often leave an inbox page open.
|
message arrives, matching the way users often leave an inbox page open.
|
||||||
|
- Help and command-output pagers now accept arrow keys, PgUp/PgDn, Home/End,
|
||||||
|
and Space/`b` in addition to the existing Vim-style keys.
|
||||||
|
- Pre-login username entry now handles Ctrl+C/Ctrl+D cancel, Ctrl+U clear
|
||||||
|
line, and Ctrl+W delete-word before the user joins the room.
|
||||||
|
- Long COMMAND-mode input is now left-truncated with a visible marker in the
|
||||||
|
status line instead of wrapping and damaging the TUI.
|
||||||
- Private-message inbox access now uses its own mutex instead of sharing the
|
- Private-message inbox access now uses its own mutex instead of sharing the
|
||||||
SSH channel write lock, reducing unrelated contention on slow clients.
|
SSH channel write lock, reducing unrelated contention on slow clients.
|
||||||
- Client writes now check the SSH channel's remote window before writing and
|
- Client writes now check the SSH channel's remote window before writing and
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,10 @@ Esc enter NORMAL mode
|
||||||
i return to INSERT mode
|
i return to INSERT mode
|
||||||
: enter COMMAND mode
|
: enter COMMAND mode
|
||||||
? open the full key reference
|
? open the full key reference
|
||||||
|
/ search message history
|
||||||
G or End jump to latest messages
|
G or End jump to latest messages
|
||||||
|
Up/Down recall sent messages in INSERT mode
|
||||||
|
Tab complete @mention in INSERT mode
|
||||||
Ctrl+C disconnect from NORMAL mode
|
Ctrl+C disconnect from NORMAL mode
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -209,7 +212,10 @@ Esc 进入 NORMAL 模式
|
||||||
i 回到 INSERT 模式
|
i 回到 INSERT 模式
|
||||||
: 输入命令
|
: 输入命令
|
||||||
? 查看完整按键参考
|
? 查看完整按键参考
|
||||||
|
/ 搜索消息历史
|
||||||
G 或 End 回到最新消息
|
G 或 End 回到最新消息
|
||||||
|
Up/Down 在 INSERT 模式调出已发送消息
|
||||||
|
Tab 在 INSERT 模式补全 @mention
|
||||||
:help 查看简明手册
|
:help 查看简明手册
|
||||||
:lang en|zh 切换界面语言
|
:lang en|zh 切换界面语言
|
||||||
:q 断开连接
|
:q 断开连接
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ The product path should stay short:
|
||||||
4. User lands in INSERT mode at the live tail and can type immediately.
|
4. User lands in INSERT mode at the live tail and can type immediately.
|
||||||
5. User presses Esc to browse history with Vim-style movement.
|
5. User presses Esc to browse history with Vim-style movement.
|
||||||
6. User uses `:help` for the concise manual or `?` for the full key reference.
|
6. User uses `:help` for the concise manual or `?` for the full key reference.
|
||||||
7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`,
|
7. User searches from NORMAL with `/term`, or uses commands when needed:
|
||||||
`:search`, `:nick`, `:mute-joins`, and `:q`.
|
`:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`,
|
||||||
|
and `:q`.
|
||||||
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
|
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
|
||||||
`stats`, `users`, `tail`, `dump`, and `post`.
|
`stats`, `users`, `tail`, `dump`, and `post`.
|
||||||
|
|
||||||
|
|
@ -23,6 +24,10 @@ The product path should stay short:
|
||||||
- INSERT mode is the default because most users arrive to send a message.
|
- INSERT mode is the default because most users arrive to send a message.
|
||||||
- NORMAL mode opens at the latest messages, not the oldest history. Users can
|
- NORMAL mode opens at the latest messages, not the oldest history. Users can
|
||||||
move upward for older context and use `G` or End to return to live chat.
|
move upward for older context and use `G` or End to return to live chat.
|
||||||
|
- NORMAL mode accepts `/` as the fast path for history search, matching a
|
||||||
|
common terminal-reader habit while reusing the existing `:search` command.
|
||||||
|
- INSERT mode keeps a small per-session sent-message history on Up/Down and
|
||||||
|
completes trailing `@mention` prefixes with Tab.
|
||||||
- `:help` is a compact manual, while `?` is a full key reference. Do not add
|
- `:help` is a compact manual, while `?` is a full key reference. Do not add
|
||||||
parallel support commands for the same task.
|
parallel support commands for the same task.
|
||||||
- Command syntax stays ASCII even in localized UI text. Translations explain;
|
- Command syntax stays ASCII even in localized UI text. Translations explain;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
" Backspace - Delete character\n"
|
" Backspace - Delete character\n"
|
||||||
" Ctrl+W - Delete last word\n"
|
" Ctrl+W - Delete last word\n"
|
||||||
" Ctrl+U - Delete line\n"
|
" Ctrl+U - Delete line\n"
|
||||||
|
" Up/Down - Recall sent messages\n"
|
||||||
|
" Tab - Complete @mention\n"
|
||||||
" Ctrl+C - Enter NORMAL mode\n"
|
" Ctrl+C - Enter NORMAL mode\n"
|
||||||
"\n"
|
"\n"
|
||||||
"NORMAL MODE KEYS:\n"
|
"NORMAL MODE KEYS:\n"
|
||||||
|
|
@ -26,6 +28,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
" Follows latest until you scroll up\n"
|
" Follows latest until you scroll up\n"
|
||||||
" i - Return to INSERT mode\n"
|
" i - Return to INSERT mode\n"
|
||||||
" : - Enter COMMAND mode\n"
|
" : - Enter COMMAND mode\n"
|
||||||
|
" / - Search message history\n"
|
||||||
" j/k - Scroll down/up one line\n"
|
" j/k - Scroll down/up one line\n"
|
||||||
" Ctrl+D/U - Scroll half page down/up\n"
|
" Ctrl+D/U - Scroll half page down/up\n"
|
||||||
" Ctrl+F/B - Scroll full page down/up\n"
|
" Ctrl+F/B - Scroll full page down/up\n"
|
||||||
|
|
@ -49,6 +52,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
" Backspace - 删除字符\n"
|
" Backspace - 删除字符\n"
|
||||||
" Ctrl+W - 删除上个单词\n"
|
" Ctrl+W - 删除上个单词\n"
|
||||||
" Ctrl+U - 删除整行\n"
|
" Ctrl+U - 删除整行\n"
|
||||||
|
" Up/Down - 调出已发送消息\n"
|
||||||
|
" Tab - 补全 @mention\n"
|
||||||
" Ctrl+C - 进入 NORMAL 模式\n"
|
" Ctrl+C - 进入 NORMAL 模式\n"
|
||||||
"\n"
|
"\n"
|
||||||
"NORMAL 模式按键:\n"
|
"NORMAL 模式按键:\n"
|
||||||
|
|
@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
" 未向上翻阅时自动跟随最新消息\n"
|
" 未向上翻阅时自动跟随最新消息\n"
|
||||||
" i - 返回 INSERT 模式\n"
|
" i - 返回 INSERT 模式\n"
|
||||||
" : - 进入 COMMAND 模式\n"
|
" : - 进入 COMMAND 模式\n"
|
||||||
|
" / - 搜索消息历史\n"
|
||||||
" j/k - 向下/上滚动一行\n"
|
" j/k - 向下/上滚动一行\n"
|
||||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||||
|
|
@ -71,9 +77,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"\n"
|
"\n"
|
||||||
"COMMAND OUTPUT KEYS:\n"
|
"COMMAND OUTPUT KEYS:\n"
|
||||||
" q, ESC - Close output\n"
|
" q, ESC - Close output\n"
|
||||||
" j/k - Scroll down/up\n"
|
" j/k, arrows - Scroll down/up\n"
|
||||||
" Ctrl+D/U - Scroll half page down/up\n"
|
" Ctrl+D/U - Scroll half page down/up\n"
|
||||||
" Ctrl+F/B - Scroll full page down/up\n"
|
" Ctrl+F/B - Scroll full page down/up\n"
|
||||||
|
" Space/b - Scroll full page down/up\n"
|
||||||
|
" PgDn/PgUp - Scroll full page down/up\n"
|
||||||
|
" End/Home - Jump to bottom/top\n"
|
||||||
" g/G - Jump to top/bottom\n"
|
" g/G - Jump to top/bottom\n"
|
||||||
" r - Refresh live output (:inbox)\n"
|
" r - Refresh live output (:inbox)\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
|
@ -83,17 +92,23 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"\n"
|
"\n"
|
||||||
"HELP SCREEN KEYS:\n"
|
"HELP SCREEN KEYS:\n"
|
||||||
" q, ESC - Close help\n"
|
" q, ESC - Close help\n"
|
||||||
" j/k - Scroll down/up\n"
|
" j/k, arrows - Scroll down/up\n"
|
||||||
" Ctrl+D/U - Scroll half page down/up\n"
|
" Ctrl+D/U - Scroll half page down/up\n"
|
||||||
" Ctrl+F/B - Scroll full page down/up\n"
|
" Ctrl+F/B - Scroll full page down/up\n"
|
||||||
|
" Space/b - Scroll full page down/up\n"
|
||||||
|
" PgDn/PgUp - Scroll full page down/up\n"
|
||||||
|
" End/Home - Jump to bottom/top\n"
|
||||||
" g/G - Jump to top/bottom\n"
|
" g/G - Jump to top/bottom\n"
|
||||||
" l - Cycle UI language\n",
|
" l - Cycle UI language\n",
|
||||||
"\n"
|
"\n"
|
||||||
"命令输出按键:\n"
|
"命令输出按键:\n"
|
||||||
" q, ESC - 关闭输出\n"
|
" q, ESC - 关闭输出\n"
|
||||||
" j/k - 向下/上滚动\n"
|
" j/k, arrows - 向下/上滚动\n"
|
||||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||||
|
" Space/b - 向下/上滚动整页\n"
|
||||||
|
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||||
|
" End/Home - 跳到底部/顶部\n"
|
||||||
" g/G - 跳到顶部/底部\n"
|
" g/G - 跳到顶部/底部\n"
|
||||||
" r - 刷新动态输出 (:inbox)\n"
|
" r - 刷新动态输出 (:inbox)\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
|
@ -103,9 +118,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"\n"
|
"\n"
|
||||||
"帮助界面按键:\n"
|
"帮助界面按键:\n"
|
||||||
" q, ESC - 关闭帮助\n"
|
" q, ESC - 关闭帮助\n"
|
||||||
" j/k - 向下/上滚动\n"
|
" j/k, arrows - 向下/上滚动\n"
|
||||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||||
|
" Space/b - 向下/上滚动整页\n"
|
||||||
|
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||||
|
" End/Home - 跳到底部/顶部\n"
|
||||||
" g/G - 跳到顶部/底部\n"
|
" g/G - 跳到顶部/底部\n"
|
||||||
" l - 切换界面语言\n"
|
" l - 切换界面语言\n"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
225
src/input.c
225
src/input.c
|
|
@ -32,10 +32,10 @@ static int read_username(client_t *client) {
|
||||||
char username[MAX_USERNAME_LEN] = {0};
|
char username[MAX_USERNAME_LEN] = {0};
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
char buf[4];
|
char buf[4];
|
||||||
|
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
|
||||||
|
|
||||||
tui_render_welcome(client);
|
tui_render_welcome(client);
|
||||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
client_printf(client, "%s", prompt);
|
||||||
I18N_USERNAME_PROMPT));
|
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
|
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
|
||||||
|
|
@ -54,6 +54,18 @@ static int read_username(client_t *client) {
|
||||||
|
|
||||||
if (b == '\r' || b == '\n') {
|
if (b == '\r' || b == '\n') {
|
||||||
break;
|
break;
|
||||||
|
} else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */
|
||||||
|
return -1;
|
||||||
|
} else if (b == 21) { /* Ctrl+U: clear line */
|
||||||
|
username[0] = '\0';
|
||||||
|
pos = 0;
|
||||||
|
client_printf(client, "\r\033[K%s", prompt);
|
||||||
|
} else if (b == 23) { /* Ctrl+W: delete word */
|
||||||
|
if (username[0] != '\0') {
|
||||||
|
utf8_remove_last_word(username);
|
||||||
|
pos = (int)strlen(username);
|
||||||
|
client_printf(client, "\r\033[K%s%s", prompt, username);
|
||||||
|
}
|
||||||
} else if (b == 127 || b == 8) { /* Backspace */
|
} else if (b == 127 || b == 8) { /* Backspace */
|
||||||
if (pos > 0) {
|
if (pos > 0) {
|
||||||
/* Compute width of the last character before removing it */
|
/* Compute width of the last character before removing it */
|
||||||
|
|
@ -230,6 +242,110 @@ static void dismiss_command_output(client_t *client) {
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
PAGER_ACTION_NONE,
|
||||||
|
PAGER_ACTION_SCROLL,
|
||||||
|
PAGER_ACTION_CLOSE,
|
||||||
|
PAGER_ACTION_REFRESH
|
||||||
|
} pager_action_t;
|
||||||
|
|
||||||
|
static int pager_page_height(client_t *client) {
|
||||||
|
int page = client->height - 2;
|
||||||
|
if (page < 1) page = 1;
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void pager_scroll_by(int *scroll_pos, int delta) {
|
||||||
|
*scroll_pos += delta;
|
||||||
|
if (*scroll_pos < 0) {
|
||||||
|
*scroll_pos = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static pager_action_t pager_apply_key(client_t *client, unsigned char key,
|
||||||
|
int *scroll_pos, bool allow_refresh) {
|
||||||
|
int page = pager_page_height(client);
|
||||||
|
int half = page / 2;
|
||||||
|
if (half < 1) half = 1;
|
||||||
|
|
||||||
|
if (key == 'q') {
|
||||||
|
return PAGER_ACTION_CLOSE;
|
||||||
|
} else if (key == 'j') {
|
||||||
|
pager_scroll_by(scroll_pos, 1);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 'k') {
|
||||||
|
pager_scroll_by(scroll_pos, -1);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||||
|
pager_scroll_by(scroll_pos, half);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||||
|
pager_scroll_by(scroll_pos, -half);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */
|
||||||
|
pager_scroll_by(scroll_pos, page);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */
|
||||||
|
pager_scroll_by(scroll_pos, -page);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 'g') {
|
||||||
|
*scroll_pos = 0;
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (key == 'G') {
|
||||||
|
*scroll_pos = 999;
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if ((key == 'r' || key == 'R') && allow_refresh) {
|
||||||
|
return PAGER_ACTION_REFRESH;
|
||||||
|
} else if (key == 27) {
|
||||||
|
char seq[3];
|
||||||
|
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
|
||||||
|
if (n != 1) {
|
||||||
|
return PAGER_ACTION_CLOSE;
|
||||||
|
}
|
||||||
|
if (seq[0] != '[') {
|
||||||
|
return PAGER_ACTION_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
|
||||||
|
if (n != 1) {
|
||||||
|
return PAGER_ACTION_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seq[1] == 'A') { /* Up arrow */
|
||||||
|
pager_scroll_by(scroll_pos, -1);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] == 'B') { /* Down arrow */
|
||||||
|
pager_scroll_by(scroll_pos, 1);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] == 'H') { /* Home */
|
||||||
|
*scroll_pos = 0;
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] == 'F') { /* End */
|
||||||
|
*scroll_pos = 999;
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] >= '1' && seq[1] <= '6') {
|
||||||
|
n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50);
|
||||||
|
if (n == 1 && seq[2] == '~') {
|
||||||
|
if (seq[1] == '5') { /* PageUp */
|
||||||
|
pager_scroll_by(scroll_pos, -page);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] == '6') { /* PageDown */
|
||||||
|
pager_scroll_by(scroll_pos, page);
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] == '1') { /* Home */
|
||||||
|
*scroll_pos = 0;
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
} else if (seq[1] == '4') { /* End */
|
||||||
|
*scroll_pos = 999;
|
||||||
|
return PAGER_ACTION_SCROLL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PAGER_ACTION_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
/* Handle a single key press. Returns true if the key was fully consumed
|
/* Handle a single key press. Returns true if the key was fully consumed
|
||||||
* (no further character buffering needed). */
|
* (no further character buffering needed). */
|
||||||
static bool handle_key(client_t *client, unsigned char key, char *input) {
|
static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
|
|
@ -257,44 +373,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
|
|
||||||
/* Handle help screen */
|
/* Handle help screen */
|
||||||
if (client->show_help) {
|
if (client->show_help) {
|
||||||
/* Page size: roughly the visible help body region. */
|
pager_action_t action;
|
||||||
int page = client->height - 2;
|
|
||||||
if (page < 1) page = 1;
|
|
||||||
int half = page / 2;
|
|
||||||
if (half < 1) half = 1;
|
|
||||||
|
|
||||||
if (key == 'q' || key == 27) {
|
if (key == 'l' || key == 'L') {
|
||||||
client->show_help = false;
|
|
||||||
tui_render_screen(client);
|
|
||||||
} else if (key == 'l' || key == 'L') {
|
|
||||||
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
|
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
|
||||||
client->help_scroll_pos = 0;
|
client->help_scroll_pos = 0;
|
||||||
tui_render_help(client);
|
tui_render_help(client);
|
||||||
} else if (key == 'j') {
|
return true;
|
||||||
client->help_scroll_pos++;
|
}
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 'k' && client->help_scroll_pos > 0) {
|
action = pager_apply_key(client, key, &client->help_scroll_pos, false);
|
||||||
client->help_scroll_pos--;
|
if (action == PAGER_ACTION_CLOSE) {
|
||||||
tui_render_help(client);
|
client->show_help = false;
|
||||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
tui_render_screen(client);
|
||||||
client->help_scroll_pos += half;
|
} else if (action == PAGER_ACTION_SCROLL) {
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
|
||||||
client->help_scroll_pos -= half;
|
|
||||||
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
|
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
|
||||||
client->help_scroll_pos += page;
|
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
|
||||||
client->help_scroll_pos -= page;
|
|
||||||
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
|
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 'g') {
|
|
||||||
client->help_scroll_pos = 0;
|
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 'G') {
|
|
||||||
client->help_scroll_pos = 999; /* Large number */
|
|
||||||
tui_render_help(client);
|
tui_render_help(client);
|
||||||
}
|
}
|
||||||
return true; /* Key consumed */
|
return true; /* Key consumed */
|
||||||
|
|
@ -303,56 +395,23 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
/* Handle command output / MOTD display. MOTD remains a simple notice;
|
/* Handle command output / MOTD display. MOTD remains a simple notice;
|
||||||
* command output behaves like a small pager so long results can be read. */
|
* command output behaves like a small pager so long results can be read. */
|
||||||
if (client->command_output[0] != '\0') {
|
if (client->command_output[0] != '\0') {
|
||||||
int page = client->height - 2;
|
pager_action_t action;
|
||||||
int half;
|
|
||||||
|
|
||||||
if (client->show_motd) {
|
if (client->show_motd) {
|
||||||
dismiss_command_output(client);
|
dismiss_command_output(client);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page < 1) page = 1;
|
action = pager_apply_key(client, key, &client->command_output_scroll,
|
||||||
half = page / 2;
|
true);
|
||||||
if (half < 1) half = 1;
|
if (action == PAGER_ACTION_CLOSE) {
|
||||||
|
|
||||||
if (key == 'q' || key == 27) {
|
|
||||||
dismiss_command_output(client);
|
dismiss_command_output(client);
|
||||||
} else if (key == 'j') {
|
} else if (action == PAGER_ACTION_SCROLL) {
|
||||||
client->command_output_scroll++;
|
|
||||||
tui_render_command_output(client);
|
tui_render_command_output(client);
|
||||||
} else if (key == 'k') {
|
} else if (action == PAGER_ACTION_REFRESH) {
|
||||||
client->command_output_scroll--;
|
if (commands_refresh_active_output(client)) {
|
||||||
if (client->command_output_scroll < 0) {
|
tui_render_command_output(client);
|
||||||
client->command_output_scroll = 0;
|
|
||||||
}
|
}
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
|
||||||
client->command_output_scroll += half;
|
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
|
||||||
client->command_output_scroll -= half;
|
|
||||||
if (client->command_output_scroll < 0) {
|
|
||||||
client->command_output_scroll = 0;
|
|
||||||
}
|
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
|
||||||
client->command_output_scroll += page;
|
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
|
||||||
client->command_output_scroll -= page;
|
|
||||||
if (client->command_output_scroll < 0) {
|
|
||||||
client->command_output_scroll = 0;
|
|
||||||
}
|
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if (key == 'g') {
|
|
||||||
client->command_output_scroll = 0;
|
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if (key == 'G') {
|
|
||||||
client->command_output_scroll = 999;
|
|
||||||
tui_render_command_output(client);
|
|
||||||
} else if ((key == 'r' || key == 'R') &&
|
|
||||||
commands_refresh_active_output(client)) {
|
|
||||||
tui_render_command_output(client);
|
|
||||||
}
|
}
|
||||||
return true; /* Key consumed */
|
return true; /* Key consumed */
|
||||||
}
|
}
|
||||||
|
|
@ -571,6 +630,12 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true;
|
return true;
|
||||||
|
} else if (key == '/') {
|
||||||
|
client->mode = MODE_COMMAND;
|
||||||
|
snprintf(client->command_input, sizeof(client->command_input),
|
||||||
|
"search ");
|
||||||
|
tui_render_screen(client);
|
||||||
|
return true;
|
||||||
} else if (key == 'j') {
|
} else if (key == 'j') {
|
||||||
normal_scroll_by(client, 1);
|
normal_scroll_by(client, 1);
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
|
@ -953,6 +1018,8 @@ main_loop:
|
||||||
client->command_input[len] = b;
|
client->command_input[len] = b;
|
||||||
client->command_input[len + 1] = '\0';
|
client->command_input[len + 1] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
} else {
|
||||||
|
client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
} else if (b >= 128) { /* UTF-8 multi-byte */
|
} else if (b >= 128) { /* UTF-8 multi-byte */
|
||||||
int char_len = utf8_byte_length(b);
|
int char_len = utf8_byte_length(b);
|
||||||
|
|
@ -965,10 +1032,12 @@ main_loop:
|
||||||
}
|
}
|
||||||
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
||||||
size_t len = strlen(client->command_input);
|
size_t len = strlen(client->command_input);
|
||||||
if (len + (size_t)char_len < sizeof(client->command_input) - 1) {
|
if (len + (size_t)char_len <= sizeof(client->command_input) - 1) {
|
||||||
memcpy(client->command_input + len, buf, char_len);
|
memcpy(client->command_input + len, buf, char_len);
|
||||||
client->command_input[len + char_len] = '\0';
|
client->command_input[len + char_len] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
} else {
|
||||||
|
client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||||
" TNT - SSH terminal chat room\n"
|
" TNT - SSH terminal chat room\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37mUse\033[0m\n"
|
"\033[1;37mUse\033[0m\n"
|
||||||
" Type a message and press Enter; Esc browses; G latest; i types\n"
|
" Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
|
||||||
" : runs commands; ? opens the full key reference\n"
|
" Esc browses; / searches; G latest; i types; : commands; ? keys\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37mCommands\033[0m\n",
|
"\033[1;37mCommands\033[0m\n",
|
||||||
"\033[1;36mTNT(1) 帮助\033[0m\n"
|
"\033[1;36mTNT(1) 帮助\033[0m\n"
|
||||||
|
|
@ -22,8 +22,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||||
" TNT - SSH 终端聊天室\n"
|
" TNT - SSH 终端聊天室\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37m使用\033[0m\n"
|
"\033[1;37m使用\033[0m\n"
|
||||||
" 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n"
|
" 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n"
|
||||||
" : 运行命令;? 打开完整按键参考\n"
|
" Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37m命令\033[0m\n"
|
"\033[1;37m命令\033[0m\n"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,54 @@
|
||||||
#include "tui_status.h"
|
#include "tui_status.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
#include "ssh_server.h"
|
#include "ssh_server.h"
|
||||||
|
#include "utf8.h"
|
||||||
|
|
||||||
|
static void format_command_input_tail(const char *input, int avail_width,
|
||||||
|
char *display, size_t display_size) {
|
||||||
|
if (!input || !display || display_size == 0) return;
|
||||||
|
|
||||||
|
display[0] = '\0';
|
||||||
|
if (avail_width < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (utf8_string_width(input) <= avail_width) {
|
||||||
|
strncpy(display, input, display_size - 1);
|
||||||
|
display[display_size - 1] = '\0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *marker = "<";
|
||||||
|
int marker_width = 1;
|
||||||
|
int tail_width = avail_width - marker_width;
|
||||||
|
if (tail_width < 1) {
|
||||||
|
snprintf(display, display_size, "%s", marker);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *p = input + strlen(input);
|
||||||
|
const char *tail = p;
|
||||||
|
int width = 0;
|
||||||
|
|
||||||
|
while (p > input && width < tail_width) {
|
||||||
|
const char *q = p - 1;
|
||||||
|
while (q > input && ((*q & 0xC0) == 0x80)) {
|
||||||
|
q--;
|
||||||
|
}
|
||||||
|
|
||||||
|
int bytes_read = 0;
|
||||||
|
uint32_t cp = utf8_decode(q, &bytes_read);
|
||||||
|
int char_width = utf8_char_width(cp);
|
||||||
|
if (width + char_width > tail_width) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
width += char_width;
|
||||||
|
tail = q;
|
||||||
|
p = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
snprintf(display, display_size, "%s%s", marker, tail);
|
||||||
|
}
|
||||||
|
|
||||||
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
||||||
const struct client *client, int msg_count,
|
const struct client *client, int msg_count,
|
||||||
|
|
@ -48,7 +96,12 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
||||||
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
||||||
}
|
}
|
||||||
} else if (client->mode == MODE_COMMAND) {
|
} else if (client->mode == MODE_COMMAND) {
|
||||||
|
char display[sizeof(client->command_input) + 2];
|
||||||
|
int avail = client->width - 1;
|
||||||
|
if (avail < 1) avail = 1;
|
||||||
|
format_command_input_tail(client->command_input, avail, display,
|
||||||
|
sizeof(display));
|
||||||
buffer_appendf(buffer, buf_size, pos,
|
buffer_appendf(buffer, buf_size, pos,
|
||||||
"\033[35m:\033[0m%s\033[K", client->command_input);
|
"\033[35m:\033[0m%s\033[K", display);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,51 @@ else
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
USERNAME_CANCEL_SCRIPT="$STATE_DIR/username-cancel.expect"
|
||||||
|
cat >"$USERNAME_CANCEL_SCRIPT" <<EOF
|
||||||
|
set timeout 10
|
||||||
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
|
sleep 1
|
||||||
|
send -- "\003"
|
||||||
|
expect eof
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if expect "$USERNAME_CANCEL_SCRIPT" >"$STATE_DIR/username-cancel.log" 2>&1; then
|
||||||
|
echo "✓ Ctrl+C cancels before username join"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "x Ctrl+C before username failed"
|
||||||
|
sed -n '1,120p' "$STATE_DIR/username-cancel.log"
|
||||||
|
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
USERNAME_EDIT_SCRIPT="$STATE_DIR/username-edit.expect"
|
||||||
|
cat >"$USERNAME_EDIT_SCRIPT" <<EOF
|
||||||
|
set timeout 10
|
||||||
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
|
sleep 1
|
||||||
|
send -- "wrong\025editeduser\r"
|
||||||
|
expect ":help"
|
||||||
|
send -- "\003"
|
||||||
|
sleep 0.2
|
||||||
|
send -- "\003"
|
||||||
|
expect eof
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if expect "$USERNAME_EDIT_SCRIPT" >"$STATE_DIR/username-edit.log" 2>&1 &&
|
||||||
|
grep -q 'editeduser' "$STATE_DIR/messages.log" &&
|
||||||
|
! grep -q 'wrongediteduser' "$STATE_DIR/messages.log"; then
|
||||||
|
echo "✓ Ctrl+U edits username before join"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "x username line editing failed"
|
||||||
|
sed -n '1,120p' "$STATE_DIR/username-edit.log" 2>/dev/null || true
|
||||||
|
cat "$STATE_DIR/messages.log" 2>/dev/null || true
|
||||||
|
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
|
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
|
||||||
cat >"$EXPECT_SCRIPT" <<EOF
|
cat >"$EXPECT_SCRIPT" <<EOF
|
||||||
set timeout 10
|
set timeout 10
|
||||||
|
|
@ -146,14 +191,17 @@ send -- ":"
|
||||||
expect ":"
|
expect ":"
|
||||||
send -- "help\r"
|
send -- "help\r"
|
||||||
expect "TNT\\(1\\) 帮助"
|
expect "TNT\\(1\\) 帮助"
|
||||||
|
expect "Tab 补全 @mention"
|
||||||
expect "q:关闭"
|
expect "q:关闭"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- "?"
|
send -- "?"
|
||||||
expect "TNT 按键参考"
|
expect "TNT 按键参考"
|
||||||
|
expect "Tab - 补全 @mention"
|
||||||
expect "l:语言"
|
expect "l:语言"
|
||||||
send -- "l"
|
send -- "l"
|
||||||
expect "TNT KEY REFERENCE"
|
expect "TNT KEY REFERENCE"
|
||||||
|
expect "Complete @mention"
|
||||||
expect "l:lang"
|
expect "l:lang"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
|
|
@ -180,6 +228,45 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
HELP_PAGER_KEYS_SCRIPT="$STATE_DIR/help-pager-keys.expect"
|
||||||
|
cat >"$HELP_PAGER_KEYS_SCRIPT" <<EOF
|
||||||
|
set timeout 10
|
||||||
|
stty rows 8 columns 80
|
||||||
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
|
sleep 1
|
||||||
|
send -- "helppager\r"
|
||||||
|
expect ":help"
|
||||||
|
send -- "\033"
|
||||||
|
expect "NORMAL"
|
||||||
|
send -- "?"
|
||||||
|
expect -re {\(1/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[6~"
|
||||||
|
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[5~"
|
||||||
|
expect -re {\(1/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[F"
|
||||||
|
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[H"
|
||||||
|
expect -re {\(1/[2-9][0-9]*\)}
|
||||||
|
send -- "q"
|
||||||
|
expect "NORMAL"
|
||||||
|
sleep 0.2
|
||||||
|
send -- "\003"
|
||||||
|
sleep 0.2
|
||||||
|
send -- "\003"
|
||||||
|
expect eof
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if expect "$HELP_PAGER_KEYS_SCRIPT" >"$STATE_DIR/help-pager-keys.log" 2>&1; then
|
||||||
|
echo "✓ help pager accepts terminal paging keys"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "x help pager terminal keys failed"
|
||||||
|
sed -n '1,220p' "$STATE_DIR/help-pager-keys.log"
|
||||||
|
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
|
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
|
||||||
cat >"$UNKNOWN_SCRIPT" <<EOF
|
cat >"$UNKNOWN_SCRIPT" <<EOF
|
||||||
set timeout 10
|
set timeout 10
|
||||||
|
|
@ -371,6 +458,14 @@ expect "j/k:滚动"
|
||||||
expect -re {\(1/[2-9][0-9]*\)}
|
expect -re {\(1/[2-9][0-9]*\)}
|
||||||
send -- "j"
|
send -- "j"
|
||||||
expect -re {\(2/[2-9][0-9]*\)}
|
expect -re {\(2/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[6~"
|
||||||
|
expect -re {\([3-9][0-9]*/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[5~"
|
||||||
|
expect -re {\([1-9][0-9]*/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[F"
|
||||||
|
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
|
||||||
|
send -- "\033\[H"
|
||||||
|
expect -re {\(1/[2-9][0-9]*\)}
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
|
|
@ -390,6 +485,37 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
COMMAND_INPUT_WRAP_SCRIPT="$STATE_DIR/command-input-wrap.expect"
|
||||||
|
cat >"$COMMAND_INPUT_WRAP_SCRIPT" <<EOF
|
||||||
|
set timeout 10
|
||||||
|
stty rows 10 columns 40
|
||||||
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
|
sleep 1
|
||||||
|
send -- "wrapcmd\r"
|
||||||
|
expect ":help"
|
||||||
|
send -- "\033"
|
||||||
|
expect "NORMAL"
|
||||||
|
send -- ":"
|
||||||
|
expect ":"
|
||||||
|
send -- "search aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaatail"
|
||||||
|
expect -re {<a+tail}
|
||||||
|
send -- "\003"
|
||||||
|
expect "NORMAL"
|
||||||
|
sleep 0.2
|
||||||
|
send -- "\003"
|
||||||
|
expect eof
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if expect "$COMMAND_INPUT_WRAP_SCRIPT" >"$STATE_DIR/command-input-wrap.log" 2>&1; then
|
||||||
|
echo "✓ long command input stays on one status line"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo "x long command input display failed"
|
||||||
|
sed -n '1,220p' "$STATE_DIR/command-input-wrap.log"
|
||||||
|
sed -n '1,120p' "$STATE_DIR/server.log"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
|
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
|
||||||
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
|
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
|
||||||
set timeout 10
|
set timeout 10
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,12 @@ expect "alpha"
|
||||||
expect "q:关闭"
|
expect "q:关闭"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
|
send -- "/alpha\r"
|
||||||
|
expect "搜索"
|
||||||
|
expect "alpha"
|
||||||
|
expect "q:关闭"
|
||||||
|
send -- "q"
|
||||||
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
expect ":"
|
expect ":"
|
||||||
send -- "mute-joins\r"
|
send -- "mute-joins\r"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue