cc98-cli 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/commands/update.d.ts.map +1 -1
  3. package/dist/cli/commands/update.js +49 -3
  4. package/dist/cli/commands/update.js.map +1 -1
  5. package/dist/storage/settings-store.d.ts +10 -0
  6. package/dist/storage/settings-store.d.ts.map +1 -0
  7. package/dist/storage/settings-store.js +37 -0
  8. package/dist/storage/settings-store.js.map +1 -0
  9. package/dist/tui/components/content.d.ts +1 -0
  10. package/dist/tui/components/content.d.ts.map +1 -1
  11. package/dist/tui/components/content.js +26 -8
  12. package/dist/tui/components/content.js.map +1 -1
  13. package/dist/tui/components/status.d.ts +3 -0
  14. package/dist/tui/components/status.d.ts.map +1 -1
  15. package/dist/tui/components/status.js +21 -8
  16. package/dist/tui/components/status.js.map +1 -1
  17. package/dist/tui/controller.d.ts +17 -0
  18. package/dist/tui/controller.d.ts.map +1 -1
  19. package/dist/tui/controller.js +465 -108
  20. package/dist/tui/controller.js.map +1 -1
  21. package/dist/tui/keybindings.d.ts +24 -0
  22. package/dist/tui/keybindings.d.ts.map +1 -0
  23. package/dist/tui/keybindings.js +207 -0
  24. package/dist/tui/keybindings.js.map +1 -0
  25. package/dist/tui/navigation.d.ts.map +1 -1
  26. package/dist/tui/navigation.js +1 -0
  27. package/dist/tui/navigation.js.map +1 -1
  28. package/dist/tui/renderer.d.ts.map +1 -1
  29. package/dist/tui/renderer.js +36 -9
  30. package/dist/tui/renderer.js.map +1 -1
  31. package/dist/tui/state/store.d.ts.map +1 -1
  32. package/dist/tui/state/store.js +13 -2
  33. package/dist/tui/state/store.js.map +1 -1
  34. package/dist/tui/state/types.d.ts +16 -0
  35. package/dist/tui/state/types.d.ts.map +1 -1
  36. package/dist/tui/topic-reader.d.ts +9 -1
  37. package/dist/tui/topic-reader.d.ts.map +1 -1
  38. package/dist/tui/topic-reader.js +23 -11
  39. package/dist/tui/topic-reader.js.map +1 -1
  40. package/dist/update.d.ts +4 -1
  41. package/dist/update.d.ts.map +1 -1
  42. package/dist/update.js +71 -11
  43. package/dist/update.js.map +1 -1
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.d.ts.map +1 -1
  46. package/dist/version.js +10 -2
  47. package/dist/version.js.map +1 -1
  48. package/package.json +1 -1
  49. package/dist/tui/components/layout.d.ts +0 -3
  50. package/dist/tui/components/layout.d.ts.map +0 -1
  51. package/dist/tui/components/layout.js +0 -453
  52. package/dist/tui/components/layout.js.map +0 -1
@@ -1,10 +1,12 @@
1
1
  import { checkForUpdate } from "../update.js";
2
2
  import { appVersion } from "../version.js";
3
3
  import { getImageCache } from "../storage/image-cache.js";
4
+ import { SettingsStore } from "../storage/settings-store.js";
5
+ import { getKeybindingManager } from "./keybindings.js";
4
6
  import { navItems, settingsItems } from "./navigation.js";
5
7
  import { getStatus } from "./state/store.js";
6
8
  import { asArray, asNumber, asObject, chatItem, chatMessageItems, flattenBoards, genericItem, historyItem, isAbortError, jsonPreviewLines, loadChatUserNames, mapLimit, noticeItem, overviewStats, topicItem, unreadStats, userItem } from "./helpers.js";
7
- import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, jumpRelativeTopicFloor } from "./topic-reader.js";
9
+ import { appendTopicPosts, buildTopicReader, currentTopicPost, findTopicPostByFloor, getTopicPageInfo, jumpToPage, FLOORS_PER_PAGE } from "./topic-reader.js";
8
10
  export class TuiController {
9
11
  state;
10
12
  client;
@@ -14,6 +16,9 @@ export class TuiController {
14
16
  nextSignal;
15
17
  abortCurrent;
16
18
  loadVersion = 0;
19
+ keybindings;
20
+ settingsStore = new SettingsStore();
21
+ updateChecked = false;
17
22
  constructor(state, client, tokenStore, render, close, nextSignal, abortCurrent) {
18
23
  this.state = state;
19
24
  this.client = client;
@@ -22,6 +27,7 @@ export class TuiController {
22
27
  this.close = close;
23
28
  this.nextSignal = nextSignal;
24
29
  this.abortCurrent = abortCurrent;
30
+ this.keybindings = getKeybindingManager();
25
31
  }
26
32
  async load(force = false) {
27
33
  const version = ++this.loadVersion;
@@ -44,7 +50,14 @@ export class TuiController {
44
50
  this.state.currentChat = undefined;
45
51
  this.render();
46
52
  try {
53
+ // 加载快捷键配置
54
+ await this.keybindings.load();
47
55
  this.state.account = await this.tokenStore.getCurrentAccountName();
56
+ // 异步检查更新(不阻塞主加载)
57
+ if (!this.updateChecked) {
58
+ this.updateChecked = true;
59
+ void this.checkUpdate();
60
+ }
48
61
  const next = await this.loadView(nav.id, force, signal);
49
62
  if (version !== this.loadVersion)
50
63
  return;
@@ -75,11 +88,20 @@ export class TuiController {
75
88
  this.handleInputKey(key);
76
89
  return;
77
90
  }
78
- if (key === "\u0003" || key === "q") {
91
+ // 关闭更新通知(Esc 或任意键)
92
+ if (this.state.updateAvailable?.isNew) {
93
+ if (key === "\x1b" || key === "\r") {
94
+ this.dismissUpdate();
95
+ return;
96
+ }
97
+ // 其他按键也关闭更新通知,继续处理原按键动作
98
+ this.dismissUpdate();
99
+ }
100
+ if (this.keybindings.matches(key, "quit")) {
79
101
  this.close();
80
102
  return;
81
103
  }
82
- if (key === "?") {
104
+ if (this.keybindings.matches(key, "help")) {
83
105
  this.state.modal = this.state.modal === "help" ? null : "help";
84
106
  this.render();
85
107
  return;
@@ -103,17 +125,17 @@ export class TuiController {
103
125
  this.handleContentKey(key);
104
126
  }
105
127
  handleInputKey(key) {
106
- if (key === "\x1b") {
128
+ if (this.keybindings.matches(key, "inputCancel")) {
107
129
  this.state.inputMode = false;
108
130
  this.state.inputValue = "";
109
131
  this.render();
110
132
  return;
111
133
  }
112
- if (key === "\r") {
134
+ if (this.keybindings.matches(key, "inputConfirm")) {
113
135
  this.state.inputCallback?.(this.state.inputValue);
114
136
  return;
115
137
  }
116
- if (key === "\x7f") {
138
+ if (this.keybindings.matches(key, "inputBackspace")) {
117
139
  this.state.inputValue = this.state.inputValue.slice(0, -1);
118
140
  this.render();
119
141
  return;
@@ -124,8 +146,19 @@ export class TuiController {
124
146
  }
125
147
  }
126
148
  handleModalKey(key) {
127
- if (this.state.modal === "help" || this.state.modal === "info") {
128
- // 帮助/信息弹窗:任意键关闭
149
+ if (this.state.modal === "help") {
150
+ this.closeModal();
151
+ return;
152
+ }
153
+ if (this.state.modal === "info") {
154
+ // 如果有确认回调,确认键执行回调,其它键关闭。
155
+ if (this.state.confirmCallback && this.keybindings.matches(key, "confirm")) {
156
+ const callback = this.state.confirmCallback;
157
+ this.state.confirmCallback = undefined;
158
+ this.closeModal();
159
+ callback();
160
+ return;
161
+ }
129
162
  this.closeModal();
130
163
  return;
131
164
  }
@@ -142,31 +175,30 @@ export class TuiController {
142
175
  }
143
176
  }
144
177
  handleSearchKey(key) {
145
- if (key === "\x1b" || key === "/") {
146
- // Esc 或 / 关闭搜索
178
+ if (this.keybindings.matches(key, "searchClose")) {
147
179
  this.state.modal = null;
148
180
  this.state.searchQuery = "";
149
181
  this.render();
150
182
  return;
151
183
  }
152
- if (key === "\t") {
184
+ if (this.keybindings.matches(key, "searchToggleMode")) {
153
185
  this.state.searchMode = this.state.searchMode === "topics" ? "users" : "topics";
154
186
  this.state.searchResults = [];
155
187
  this.state.itemIndex = 0;
156
188
  this.render();
157
189
  return;
158
190
  }
159
- if ((key === "j" || key === "\x1b[B") && this.state.searchResults.length > 0) {
191
+ if (this.keybindings.matches(key, "searchNext") && this.state.searchResults.length > 0) {
160
192
  this.state.itemIndex = Math.min(this.state.searchResults.length - 1, this.state.itemIndex + 1);
161
193
  this.render();
162
194
  return;
163
195
  }
164
- if ((key === "k" || key === "\x1b[A") && this.state.searchResults.length > 0) {
196
+ if (this.keybindings.matches(key, "searchPrev") && this.state.searchResults.length > 0) {
165
197
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
166
198
  this.render();
167
199
  return;
168
200
  }
169
- if (key === "\r") {
201
+ if (this.keybindings.matches(key, "searchExecute")) {
170
202
  const selected = this.state.searchResults[this.state.itemIndex];
171
203
  if (selected) {
172
204
  // 有选中项:打开
@@ -222,23 +254,22 @@ export class TuiController {
222
254
  }
223
255
  }
224
256
  handleMenuKey(key) {
225
- if (key === "j" || key === "\x1b[B") {
257
+ if (this.keybindings.matches(key, "menuNext")) {
226
258
  this.state.menuIndex = Math.min(Math.max(0, this.state.menuItems.length - 1), this.state.menuIndex + 1);
227
259
  this.render();
228
260
  return;
229
261
  }
230
- if (key === "k" || key === "\x1b[A") {
262
+ if (this.keybindings.matches(key, "menuPrev")) {
231
263
  this.state.menuIndex = Math.max(0, this.state.menuIndex - 1);
232
264
  this.render();
233
265
  return;
234
266
  }
235
- if (key === "\x1b" || key === "o") {
236
- // Esc 或 o 关闭菜单
267
+ if (this.keybindings.matches(key, "menuClose")) {
237
268
  this.state.modal = null;
238
269
  this.render();
239
270
  return;
240
271
  }
241
- if (key === "\r" || key === "l") {
272
+ if (this.keybindings.matches(key, "menuExecute")) {
242
273
  // Enter 或 l 执行选中项
243
274
  const selected = this.state.menuItems[this.state.menuIndex];
244
275
  this.state.modal = null;
@@ -250,84 +281,137 @@ export class TuiController {
250
281
  }
251
282
  }
252
283
  handleTopicKey(key) {
284
+ // 数字输入:收集跳转目标
253
285
  if (/^\d$/.test(key) && this.state.topic) {
254
286
  this.state.topic.floorInput = `${this.state.topic.floorInput}${key}`.slice(0, 6);
255
- this.state.status = `跳转到 ${this.state.topic.floorInput} 楼:Enter 确认 Esc 取消`;
287
+ this.state.status = `输入: ${this.state.topic.floorInput}`;
256
288
  this.render();
257
289
  return;
258
290
  }
259
- if (key === "\x7f" && this.state.topic?.floorInput) {
291
+ // 退格:删除输入
292
+ if (this.keybindings.matches(key, "inputBackspace") && this.state.topic?.floorInput) {
260
293
  this.state.topic.floorInput = this.state.topic.floorInput.slice(0, -1);
261
- this.state.status = this.state.topic.floorInput ? `跳转到 ${this.state.topic.floorInput} 楼:Enter 确认 Esc 取消` : getStatus(this.state);
294
+ this.state.status = this.state.topic.floorInput ? `输入: ${this.state.topic.floorInput}` : "";
262
295
  this.render();
263
296
  return;
264
297
  }
265
- if (key === "\r" && this.state.topic?.floorInput) {
266
- const floor = Number(this.state.topic.floorInput);
298
+ // 数字 + 跳页键:跳页
299
+ if (this.keybindings.matches(key, "topicJumpPage") && this.state.topic?.floorInput) {
300
+ const page = Number(this.state.topic.floorInput);
301
+ this.state.topic.jumpTarget = { type: "page", value: page };
267
302
  this.state.topic.floorInput = "";
268
- if (Number.isInteger(floor) && floor > 0)
269
- void this.jumpToTopicFloor(floor, this.nextSignal());
303
+ this.state.status = `跳转到第 ${page} 页?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
304
+ this.render();
270
305
  return;
271
306
  }
272
- if ((key === "]" || key === "】") && this.state.topic) {
273
- this.state.scroll = jumpRelativeTopicFloor(this.state.topic, this.state.scroll, 1);
274
- this.state.status = getStatus(this.state);
307
+ // 数字 + 跳楼键:跳楼
308
+ if (this.keybindings.matches(key, "topicJumpFloor") && this.state.topic?.floorInput) {
309
+ const floor = Number(this.state.topic.floorInput);
310
+ this.state.topic.jumpTarget = { type: "floor", value: floor };
311
+ this.state.topic.floorInput = "";
312
+ this.state.status = `跳转到第 ${floor} 楼?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
275
313
  this.render();
276
314
  return;
277
315
  }
278
- if ((key === "[" || key === "【") && this.state.topic) {
279
- this.state.scroll = jumpRelativeTopicFloor(this.state.topic, this.state.scroll, -1);
280
- this.state.status = getStatus(this.state);
316
+ // 跳到最后一页
317
+ if (this.keybindings.matches(key, "topicJumpLast") && !this.state.topic?.floorInput && this.state.topic) {
318
+ const pageInfo = getTopicPageInfo(this.state.topic, this.state.topic.cursorLine);
319
+ this.state.topic.jumpTarget = { type: "page", value: pageInfo.totalPages };
320
+ this.state.status = `跳转到最后一页(第 ${pageInfo.totalPages} 页)?${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`;
281
321
  this.render();
282
322
  return;
283
323
  }
284
- if (key === "\x1b" && this.state.topic?.floorInput) {
324
+ // 确认跳转
325
+ if (this.keybindings.matches(key, "confirm") && this.state.topic?.jumpTarget) {
326
+ const target = this.state.topic.jumpTarget;
327
+ this.state.topic.jumpTarget = undefined;
328
+ this.state.status = "";
329
+ if (target.type === "page") {
330
+ void this.jumpToTopicPage(target.value, this.nextSignal());
331
+ }
332
+ else {
333
+ void this.jumpToTopicFloor(target.value, this.nextSignal());
334
+ }
335
+ return;
336
+ }
337
+ // 取消跳转
338
+ if (this.keybindings.matches(key, "back") && (this.state.topic?.floorInput || this.state.topic?.jumpTarget)) {
285
339
  this.state.topic.floorInput = "";
286
- this.state.status = getStatus(this.state);
340
+ this.state.topic.jumpTarget = undefined;
341
+ this.state.status = "";
287
342
  this.render();
288
343
  return;
289
344
  }
290
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
345
+ // ]:下一层
346
+ if (this.keybindings.matches(key, "topicNextFloor") && this.state.topic) {
347
+ void this.jumpRelativeFloor(1);
348
+ return;
349
+ }
350
+ // [:上一层
351
+ if (this.keybindings.matches(key, "topicPrevFloor") && this.state.topic) {
352
+ void this.jumpRelativeFloor(-1);
353
+ return;
354
+ }
355
+ // }:下一页
356
+ if (this.keybindings.matches(key, "topicNextPage") && this.state.topic) {
357
+ void this.jumpToNextPage();
358
+ return;
359
+ }
360
+ // {:上一页
361
+ if (this.keybindings.matches(key, "topicPrevPage") && this.state.topic) {
362
+ void this.jumpToPrevPage();
363
+ return;
364
+ }
365
+ // h/Esc:返回
366
+ if (this.keybindings.matches(key, "back")) {
291
367
  this.leave();
292
368
  return;
293
369
  }
294
- if (key === "j" || key === "\x1b[B") {
370
+ // j:下移
371
+ if (this.keybindings.matches(key, "topicScrollDown")) {
295
372
  const maxScroll = Math.max(0, (this.state.topic?.lines.length ?? 0) - 1);
296
- const wasAtEnd = this.state.scroll >= maxScroll;
297
- this.state.scroll = Math.min(maxScroll, this.state.scroll + 1);
298
- this.render();
299
- if (wasAtEnd && this.state.topic?.hasMore && !this.state.loadingMore) {
300
- void this.loadNextTopicPage(this.nextSignal(), true);
373
+ if (this.state.topic) {
374
+ this.state.topic.cursorLine = Math.min(maxScroll, this.state.topic.cursorLine + 1);
301
375
  }
302
- return;
303
- }
304
- if (key === "k" || key === "\x1b[A") {
305
- this.state.scroll = Math.max(0, this.state.scroll - 1);
376
+ this.state.status = "";
306
377
  this.render();
378
+ void this.checkAutoLoad();
307
379
  return;
308
380
  }
309
- if (key === "n" || key === " ") {
310
- void this.loadNextTopicPage(this.nextSignal());
381
+ // k:上移
382
+ if (this.keybindings.matches(key, "topicScrollUp")) {
383
+ if (this.state.topic) {
384
+ this.state.topic.cursorLine = Math.max(0, this.state.topic.cursorLine - 1);
385
+ }
386
+ this.state.status = "";
387
+ this.render();
311
388
  return;
312
389
  }
313
- if (key === "r" && this.state.topic) {
390
+ // r:刷新
391
+ if (this.keybindings.matches(key, "topicRefresh") && this.state.topic) {
314
392
  void this.openTopic(this.state.topic.topicId, true, this.nextSignal());
315
393
  return;
316
394
  }
317
- if (key === "s")
395
+ // s:收藏
396
+ if (this.keybindings.matches(key, "topicFavorite"))
318
397
  void this.toggleFavorite();
319
- if (key === "l")
398
+ // l:点赞
399
+ if (this.keybindings.matches(key, "topicLike"))
320
400
  void this.reactToCurrentPost(true);
321
- if (key === "d")
401
+ // d:踩
402
+ if (this.keybindings.matches(key, "topicDislike"))
322
403
  void this.reactToCurrentPost(false);
323
- if (key === "u")
404
+ // u:查看用户
405
+ if (this.keybindings.matches(key, "topicUser"))
324
406
  void this.showCurrentUser(this.nextSignal());
325
- if (key === "v")
407
+ // v:查看投票
408
+ if (this.keybindings.matches(key, "topicVote"))
326
409
  void this.showTopicVote(this.nextSignal());
327
- if (key === "a")
410
+ // a:查看评价
411
+ if (this.keybindings.matches(key, "topicReaction"))
328
412
  void this.showPostReactionState(this.nextSignal());
329
- if (key === "o") {
330
- // 如果当前行是图片行,打开图片;否则打开菜单
413
+ // o:打开图片/菜单
414
+ if (this.keybindings.matches(key, "topicOpenImage")) {
331
415
  const currentLine = this.getCurrentTopicLine();
332
416
  if (currentLine?.kind === "image" && currentLine.imageUrl) {
333
417
  void this.openImage(currentLine.imageUrl);
@@ -336,8 +420,8 @@ export class TuiController {
336
420
  this.openMenu();
337
421
  }
338
422
  }
339
- if (key === "c") {
340
- // 复制图片链接
423
+ // c:复制链接
424
+ if (this.keybindings.matches(key, "topicCopyLink")) {
341
425
  const currentLine = this.getCurrentTopicLine();
342
426
  if (currentLine?.kind === "image" && currentLine.imageUrl) {
343
427
  void this.copyToClipboard(currentLine.imageUrl);
@@ -348,39 +432,39 @@ export class TuiController {
348
432
  }
349
433
  }
350
434
  handleSettingsKey(key) {
351
- if (key === "j" || key === "\x1b[B") {
435
+ if (this.keybindings.matches(key, "moveDown")) {
352
436
  this.state.itemIndex = Math.min(settingsItems.length - 1, this.state.itemIndex + 1);
353
437
  this.render();
354
438
  return;
355
439
  }
356
- if (key === "k" || key === "\x1b[A") {
440
+ if (this.keybindings.matches(key, "moveUp")) {
357
441
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
358
442
  this.render();
359
443
  return;
360
444
  }
361
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
445
+ if (this.keybindings.matches(key, "back")) {
362
446
  this.state.mode = "list";
363
447
  this.state.focus = "nav";
364
448
  this.state.status = getStatus(this.state);
365
449
  this.render();
366
450
  return;
367
451
  }
368
- if (key === "l" || key === "\x1b[C" || key === "\r") {
452
+ if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
369
453
  void this.activateSetting(settingsItems[this.state.itemIndex]);
370
454
  }
371
455
  }
372
456
  handleNavKey(key) {
373
- if (key === "j" || key === "\x1b[B") {
457
+ if (this.keybindings.matches(key, "moveDown")) {
374
458
  this.state.navIndex = Math.min(navItems.length - 1, this.state.navIndex + 1);
375
459
  void this.load();
376
460
  return;
377
461
  }
378
- if (key === "k" || key === "\x1b[A") {
462
+ if (this.keybindings.matches(key, "moveUp")) {
379
463
  this.state.navIndex = Math.max(0, this.state.navIndex - 1);
380
464
  void this.load();
381
465
  return;
382
466
  }
383
- if (key === "l" || key === "\x1b[C" || key === "\r") {
467
+ if (this.keybindings.matches(key, "confirm") || this.keybindings.matches(key, "moveRight")) {
384
468
  if (!this.state.loading && this.state.items.length > 0) {
385
469
  if (navItems[this.state.navIndex]?.id === "settings")
386
470
  this.state.mode = "settings";
@@ -391,25 +475,25 @@ export class TuiController {
391
475
  }
392
476
  return;
393
477
  }
394
- if (key === "r")
478
+ if (this.keybindings.matches(key, "refresh"))
395
479
  void this.load(true);
396
480
  }
397
481
  handleContentKey(key) {
398
- if (key === "j" || key === "\x1b[B") {
482
+ if (this.keybindings.matches(key, "listNext")) {
399
483
  this.state.itemIndex = Math.min(Math.max(0, this.state.items.length - 1), this.state.itemIndex + 1);
400
484
  this.render();
401
485
  return;
402
486
  }
403
- if (key === "k" || key === "\x1b[A") {
487
+ if (this.keybindings.matches(key, "listPrev")) {
404
488
  this.state.itemIndex = Math.max(0, this.state.itemIndex - 1);
405
489
  this.render();
406
490
  return;
407
491
  }
408
- if (key === "h" || key === "\x1b[D" || key === "\x1b") {
492
+ if (this.keybindings.matches(key, "listBack")) {
409
493
  this.leave();
410
494
  return;
411
495
  }
412
- if (key === "l" || key === "\x1b[C" || key === "\r") {
496
+ if (this.keybindings.matches(key, "listOpen")) {
413
497
  const selected = this.state.items[this.state.itemIndex];
414
498
  if (selected) {
415
499
  void this.activateContentItem(selected, this.nextSignal());
@@ -420,15 +504,11 @@ export class TuiController {
420
504
  }
421
505
  return;
422
506
  }
423
- if ((key === "n" || key === " ") && this.state.currentChat) {
424
- void this.loadNextChatPage(this.nextSignal());
425
- return;
426
- }
427
- if (key === "r")
507
+ if (this.keybindings.matches(key, "listRefresh"))
428
508
  void this.refresh();
429
- if (key === "/")
509
+ if (this.keybindings.matches(key, "search"))
430
510
  this.openSearch();
431
- if (key === "o")
511
+ if (this.keybindings.matches(key, "menu"))
432
512
  this.openMenu();
433
513
  }
434
514
  leave() {
@@ -643,34 +723,27 @@ export class TuiController {
643
723
  this.render();
644
724
  return;
645
725
  }
726
+ if (selected.meta === "keybindings") {
727
+ void this.openKeybindingEditor();
728
+ return;
729
+ }
646
730
  if (selected.meta === "cache") {
647
- this.state.status = "正在清理缓存...";
648
- this.render();
649
- try {
650
- await this.client.clearCache();
651
- this.state.status = "缓存已清理";
652
- await this.load(true);
653
- }
654
- catch {
655
- this.state.status = "缓存清理失败";
656
- this.render();
657
- }
731
+ void this.openCacheManager();
658
732
  return;
659
733
  }
660
734
  if (selected.meta === "update") {
661
- this.state.status = "正在检查 GitHub Release...";
662
- this.render();
663
- try {
664
- const result = await checkForUpdate();
665
- this.state.status = result.message;
666
- }
667
- catch (error) {
668
- this.state.status = error instanceof Error ? error.message : "检查更新失败";
669
- }
670
- this.render();
735
+ void this.checkUpdate(true);
671
736
  return;
672
737
  }
673
- this.state.status = selected.meta === "logout" ? "退出登录功能开发中..." : "账号切换功能开发中...";
738
+ if (selected.meta === "account") {
739
+ void this.openAccountSwitcher();
740
+ return;
741
+ }
742
+ if (selected.meta === "logout") {
743
+ void this.confirmLogout();
744
+ return;
745
+ }
746
+ this.state.status = "功能开发中...";
674
747
  this.render();
675
748
  }
676
749
  async activateContentItem(selected, signal) {
@@ -690,6 +763,12 @@ export class TuiController {
690
763
  await this.showUserDetailById(selected.userId, signal);
691
764
  return;
692
765
  }
766
+ // 账号切换
767
+ if (selected.meta?.startsWith("account:")) {
768
+ const accountName = selected.meta.slice(8);
769
+ await this.switchAccount(accountName);
770
+ return;
771
+ }
693
772
  if (selected.action?.startsWith("notices:")) {
694
773
  await this.openNoticeList(selected.action.split(":")[1], signal);
695
774
  return;
@@ -800,6 +879,94 @@ export class TuiController {
800
879
  this.render();
801
880
  }
802
881
  }
882
+ async checkAutoLoad() {
883
+ const topic = this.state.topic;
884
+ if (!topic?.hasMore || this.state.loadingMore)
885
+ return;
886
+ const viewportRows = Math.max(1, topic.viewportRows);
887
+ const viewportBottom = this.state.scroll + viewportRows;
888
+ if (viewportBottom >= topic.lines.length) {
889
+ void this.loadNextTopicPage(this.nextSignal(), true);
890
+ }
891
+ }
892
+ async jumpRelativeFloor(delta) {
893
+ const topic = this.state.topic;
894
+ if (!topic)
895
+ return;
896
+ const current = currentTopicPost(topic, topic.cursorLine);
897
+ const currentFloor = current?.floor ?? 1;
898
+ const targetFloor = currentFloor + delta;
899
+ if (targetFloor < 1)
900
+ return;
901
+ const loaded = findTopicPostByFloor(topic, targetFloor);
902
+ if (loaded) {
903
+ topic.cursorLine = loaded.lineStart;
904
+ this.state.status = "";
905
+ this.render();
906
+ if (delta > 0)
907
+ void this.checkAutoLoad();
908
+ return;
909
+ }
910
+ if (delta > 0 && topic.hasMore && !this.state.loadingMore) {
911
+ await this.jumpToTopicFloor(targetFloor, this.nextSignal());
912
+ }
913
+ }
914
+ async jumpToNextPage() {
915
+ const topic = this.state.topic;
916
+ if (!topic)
917
+ return;
918
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
919
+ if (pageInfo.currentPage < pageInfo.totalPages) {
920
+ await this.jumpToTopicPage(pageInfo.currentPage + 1, this.nextSignal());
921
+ }
922
+ else if (topic.hasMore && !this.state.loadingMore) {
923
+ // 当前是最后一页,但还有更多内容,加载下一页
924
+ await this.loadNextTopicPage(this.nextSignal());
925
+ const newPageInfo = getTopicPageInfo(topic, topic.cursorLine);
926
+ topic.cursorLine = jumpToPage(topic, newPageInfo.currentPage + 1);
927
+ this.state.status = "";
928
+ this.render();
929
+ }
930
+ }
931
+ async jumpToPrevPage() {
932
+ const topic = this.state.topic;
933
+ if (!topic)
934
+ return;
935
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
936
+ if (pageInfo.currentPage > 1) {
937
+ topic.cursorLine = jumpToPage(topic, pageInfo.currentPage - 1);
938
+ this.state.status = "";
939
+ this.render();
940
+ }
941
+ }
942
+ async jumpToTopicPage(page, signal) {
943
+ const topic = this.state.topic;
944
+ if (!topic)
945
+ return;
946
+ const pageInfo = getTopicPageInfo(topic, topic.cursorLine);
947
+ if (page < 1 || page > pageInfo.totalPages) {
948
+ this.state.status = `未找到第 ${page} 页`;
949
+ this.render();
950
+ return;
951
+ }
952
+ // 如果目标页未加载,需要加载到该页
953
+ const targetFloor = (page - 1) * FLOORS_PER_PAGE + 1;
954
+ while (topic.hasMore && !findTopicPostByFloor(topic, targetFloor)) {
955
+ const previousLoaded = topic.loaded;
956
+ await this.loadNextTopicPage(signal, true);
957
+ if (topic.loaded === previousLoaded)
958
+ break;
959
+ }
960
+ const post = findTopicPostByFloor(topic, targetFloor);
961
+ if (post) {
962
+ topic.cursorLine = post.lineStart;
963
+ this.state.status = "";
964
+ }
965
+ else {
966
+ this.state.status = `未找到第 ${page} 页`;
967
+ }
968
+ this.render();
969
+ }
803
970
  async loadNextTopicPage(signal, quiet = false) {
804
971
  const topic = this.state.topic;
805
972
  if (!topic?.hasMore || this.state.loadingMore)
@@ -810,7 +977,7 @@ export class TuiController {
810
977
  try {
811
978
  const posts = asArray(await this.client.getTopicPosts(topic.topicId, topic.loaded, topic.size, false, signal));
812
979
  appendTopicPosts(topic, posts);
813
- this.state.status = topic.hasMore ? "已加载下一页" : "已到底";
980
+ this.state.status = "";
814
981
  }
815
982
  catch (error) {
816
983
  if (!isAbortError(error))
@@ -827,17 +994,20 @@ export class TuiController {
827
994
  return;
828
995
  const loaded = findTopicPostByFloor(topic, floor);
829
996
  if (loaded) {
830
- this.state.scroll = loaded.lineStart;
831
- this.state.status = getStatus(this.state);
997
+ topic.cursorLine = loaded.lineStart;
998
+ this.state.status = "";
832
999
  this.render();
833
1000
  return;
834
1001
  }
835
1002
  while (topic.hasMore && !findTopicPostByFloor(topic, floor)) {
1003
+ const previousLoaded = topic.loaded;
836
1004
  await this.loadNextTopicPage(signal, true);
1005
+ if (topic.loaded === previousLoaded)
1006
+ break;
837
1007
  }
838
1008
  const post = findTopicPostByFloor(topic, floor);
839
- this.state.scroll = post?.lineStart ?? this.state.scroll;
840
- this.state.status = post ? getStatus(this.state) : `未找到 ${floor} 楼`;
1009
+ topic.cursorLine = post?.lineStart ?? topic.cursorLine;
1010
+ this.state.status = post ? "" : `未找到 ${floor} 楼`;
841
1011
  this.render();
842
1012
  }
843
1013
  async runReadOnlyAction(action, signal) {
@@ -955,7 +1125,7 @@ export class TuiController {
955
1125
  const topic = this.state.topic;
956
1126
  if (!topic)
957
1127
  return;
958
- const post = currentTopicPost(topic, this.state.scroll);
1128
+ const post = currentTopicPost(topic, topic.cursorLine);
959
1129
  if (!post?.id) {
960
1130
  this.state.status = "当前楼层没有可操作的帖子 ID";
961
1131
  this.render();
@@ -974,7 +1144,7 @@ export class TuiController {
974
1144
  const topic = this.state.topic;
975
1145
  if (!topic)
976
1146
  return;
977
- const post = currentTopicPost(topic, this.state.scroll);
1147
+ const post = currentTopicPost(topic, topic.cursorLine);
978
1148
  if (!post?.userId) {
979
1149
  this.state.status = "当前楼层没有用户 ID";
980
1150
  this.render();
@@ -1030,7 +1200,7 @@ export class TuiController {
1030
1200
  const topic = this.state.topic;
1031
1201
  if (!topic)
1032
1202
  return;
1033
- const post = currentTopicPost(topic, this.state.scroll);
1203
+ const post = currentTopicPost(topic, topic.cursorLine);
1034
1204
  if (!post?.id)
1035
1205
  return;
1036
1206
  try {
@@ -1081,7 +1251,7 @@ export class TuiController {
1081
1251
  if (!topic)
1082
1252
  return undefined;
1083
1253
  for (const post of topic.posts) {
1084
- const line = post.lines.find((entry) => entry.line === this.state.scroll);
1254
+ const line = post.lines.find((entry) => entry.line === topic.cursorLine);
1085
1255
  if (line)
1086
1256
  return line;
1087
1257
  }
@@ -1148,6 +1318,147 @@ export class TuiController {
1148
1318
  }
1149
1319
  this.render();
1150
1320
  }
1321
+ async openKeybindingEditor() {
1322
+ // 显示快捷键配置信息
1323
+ const config = this.keybindings.getConfig();
1324
+ const lines = [
1325
+ "快捷键配置文件: ~/.cc98-cli/keybindings.json",
1326
+ "",
1327
+ "当前配置:"
1328
+ ];
1329
+ // 显示主要快捷键
1330
+ const mainActions = [
1331
+ "moveUp", "moveDown", "moveLeft", "moveRight", "confirm", "back",
1332
+ "search", "refresh", "menu", "help", "quit",
1333
+ "topicNextPage", "topicPrevPage", "topicNextFloor", "topicPrevFloor",
1334
+ "topicJumpPage", "topicJumpFloor", "topicJumpLast"
1335
+ ];
1336
+ for (const action of mainActions) {
1337
+ const keys = config[action] ?? [];
1338
+ const desc = this.keybindings.getActionDescription(action);
1339
+ const keyStr = keys.map(k => this.keybindings.formatKey(k)).join("/");
1340
+ lines.push(` ${desc}: ${keyStr}`);
1341
+ }
1342
+ lines.push("", "编辑配置文件后重启生效。", "", "按 Esc 返回设置");
1343
+ this.state.modal = "info";
1344
+ this.state.infoTitle = "快捷键设置";
1345
+ this.state.infoLines = lines;
1346
+ this.render();
1347
+ }
1348
+ async openAccountSwitcher() {
1349
+ try {
1350
+ const accounts = await this.tokenStore.listAccounts();
1351
+ const currentAccount = await this.tokenStore.getCurrentAccountName();
1352
+ if (accounts.length === 0) {
1353
+ this.state.modal = "info";
1354
+ this.state.infoTitle = "切换账号";
1355
+ this.state.infoLines = ["暂无保存的账号", "", "请先登录账号。"];
1356
+ this.render();
1357
+ return;
1358
+ }
1359
+ // 构建账号列表
1360
+ const items = accounts.map(account => ({
1361
+ title: account.displayName || account.username || account.account,
1362
+ meta: `account:${account.account}`,
1363
+ detail: `${account.account === currentAccount ? "✓ 当前" : "切换到此账号"}${account.userId ? ` · ID: ${account.userId}` : ""}`
1364
+ }));
1365
+ this.snapshotParent();
1366
+ this.state.viewTitle = "切换账号";
1367
+ this.state.items = items;
1368
+ this.state.stats = [{ title: "账号数", detail: `${accounts.length}` }];
1369
+ this.state.itemIndex = accounts.findIndex(a => a.account === currentAccount);
1370
+ this.state.scroll = 0;
1371
+ this.state.focus = "content";
1372
+ this.state.mode = "list";
1373
+ this.state.status = "选择账号: j/k 选择 Enter 切换 h 返回";
1374
+ this.render();
1375
+ }
1376
+ catch (error) {
1377
+ this.state.status = error instanceof Error ? error.message : "读取账号失败";
1378
+ this.render();
1379
+ }
1380
+ }
1381
+ async switchAccount(accountName) {
1382
+ try {
1383
+ await this.tokenStore.useAccount(accountName);
1384
+ this.state.status = `已切换到账号: ${accountName}`;
1385
+ this.state.parentList = undefined;
1386
+ this.state.mode = "list";
1387
+ this.state.focus = "nav";
1388
+ await this.load(true);
1389
+ }
1390
+ catch (error) {
1391
+ this.state.status = error instanceof Error ? error.message : "切换账号失败";
1392
+ this.render();
1393
+ }
1394
+ }
1395
+ async confirmLogout() {
1396
+ const account = await this.tokenStore.getCurrentAccountName();
1397
+ const lines = [
1398
+ `当前账号: ${account || "未知"}`,
1399
+ "",
1400
+ "退出登录将清除所有保存的账号信息。",
1401
+ "清除后需要重新登录。",
1402
+ "",
1403
+ `${this.keybindings.formatActionKeys("confirm")} 确认 ${this.keybindings.formatActionKeys("back")} 取消`
1404
+ ];
1405
+ this.state.modal = "info";
1406
+ this.state.infoTitle = "退出登录";
1407
+ this.state.infoLines = lines;
1408
+ this.state.confirmCallback = () => void this.performLogout();
1409
+ this.render();
1410
+ }
1411
+ async performLogout() {
1412
+ try {
1413
+ await this.tokenStore.clear();
1414
+ this.state.status = "已退出登录";
1415
+ this.state.parentList = undefined;
1416
+ this.state.mode = "list";
1417
+ this.state.focus = "nav";
1418
+ await this.load(true);
1419
+ }
1420
+ catch (error) {
1421
+ this.state.status = error instanceof Error ? error.message : "退出失败";
1422
+ this.render();
1423
+ }
1424
+ }
1425
+ async openCacheManager() {
1426
+ try {
1427
+ const stats = await this.client.getCacheStats();
1428
+ const cacheDir = "~/.cc98-cli/cache/";
1429
+ const lines = [
1430
+ `缓存目录: ${cacheDir}`,
1431
+ `文件数量: ${stats.fileCacheEntries}`,
1432
+ "",
1433
+ "缓存策略:",
1434
+ " 版面主题: 30s",
1435
+ " 版面信息: 24h",
1436
+ " 用户信息: 5min",
1437
+ "",
1438
+ "Enter 清理缓存 Esc 返回"
1439
+ ];
1440
+ this.state.modal = "info";
1441
+ this.state.infoTitle = "缓存管理";
1442
+ this.state.infoLines = lines;
1443
+ this.state.confirmCallback = () => void this.clearCache();
1444
+ this.render();
1445
+ }
1446
+ catch (error) {
1447
+ this.state.status = error instanceof Error ? error.message : "读取缓存信息失败";
1448
+ this.render();
1449
+ }
1450
+ }
1451
+ async clearCache() {
1452
+ try {
1453
+ await this.client.clearCache();
1454
+ this.state.status = "缓存已清理";
1455
+ await this.load(true);
1456
+ }
1457
+ catch {
1458
+ this.state.status = "缓存清理失败";
1459
+ this.render();
1460
+ }
1461
+ }
1151
1462
  async openFriendUsers(type, signal) {
1152
1463
  const ids = asArray(await this.client.getFriendIds(type, 0, 20, false, signal)).filter((id) => typeof id === "number");
1153
1464
  const users = asArray(await this.client.getUsers(ids, false, signal));
@@ -1196,5 +1507,51 @@ export class TuiController {
1196
1507
  this.state.focus = "content";
1197
1508
  this.state.scroll = 0;
1198
1509
  }
1510
+ async checkUpdate(forceShow = false) {
1511
+ if (forceShow) {
1512
+ this.state.status = "正在检查 GitHub Release...";
1513
+ this.render();
1514
+ }
1515
+ try {
1516
+ const result = await checkForUpdate();
1517
+ if (!result.updateAvailable || !result.latest) {
1518
+ this.state.updateAvailable = undefined;
1519
+ if (forceShow) {
1520
+ this.state.status = result.message;
1521
+ this.render();
1522
+ }
1523
+ return;
1524
+ }
1525
+ const lastSeen = await this.settingsStore.getLastSeenVersion();
1526
+ const isNew = forceShow || lastSeen !== result.latest.version;
1527
+ this.state.updateAvailable = {
1528
+ version: result.latest.version,
1529
+ tagName: result.latest.tagName,
1530
+ url: result.latest.url,
1531
+ body: result.latest.body,
1532
+ isNew
1533
+ };
1534
+ if (forceShow) {
1535
+ this.state.status = result.message;
1536
+ }
1537
+ this.render();
1538
+ }
1539
+ catch (error) {
1540
+ if (forceShow) {
1541
+ this.state.status = error instanceof Error ? error.message : "检查更新失败";
1542
+ this.render();
1543
+ }
1544
+ }
1545
+ }
1546
+ dismissUpdate() {
1547
+ if (this.state.updateAvailable) {
1548
+ const version = this.state.updateAvailable.version;
1549
+ this.state.updateAvailable = undefined;
1550
+ this.render();
1551
+ void this.settingsStore.setLastSeenVersion(version).catch(() => {
1552
+ // 忽略已读状态写入失败,避免影响 TUI 操作。
1553
+ });
1554
+ }
1555
+ }
1199
1556
  }
1200
1557
  //# sourceMappingURL=controller.js.map