pi-lazy-panel 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/README.zh.md +104 -0
- package/docs/design.md +235 -0
- package/docs/keybindings.md +172 -0
- package/docs/keybindings.zh.md +172 -0
- package/package.json +67 -0
- package/src/actions/session-actions.ts +585 -0
- package/src/actions/tree-actions.ts +161 -0
- package/src/config/config.ts +155 -0
- package/src/config/keymap.ts +219 -0
- package/src/config/keys.ts +279 -0
- package/src/config/pi-settings.ts +54 -0
- package/src/constants.ts +48 -0
- package/src/data/changelog.ts +63 -0
- package/src/data/content.ts +178 -0
- package/src/data/search.ts +183 -0
- package/src/data/sessions.ts +148 -0
- package/src/data/tree-fold.ts +163 -0
- package/src/data/tree.ts +258 -0
- package/src/i18n/index.ts +103 -0
- package/src/i18n/locales/en.json +341 -0
- package/src/i18n/locales/zh.json +341 -0
- package/src/index.ts +155 -0
- package/src/types.ts +346 -0
- package/src/ui/app.ts +2892 -0
- package/src/ui/frame.ts +116 -0
- package/src/ui/mouse-input.ts +197 -0
- package/src/ui/mouse.ts +99 -0
- package/src/ui/panes/content-pane.ts +132 -0
- package/src/ui/panes/sessions-pane.ts +161 -0
- package/src/ui/panes/tree-pane.ts +152 -0
- package/src/ui/search-highlight.ts +108 -0
- package/src/ui/tree-lines.ts +111 -0
- package/src/ui/tree-outline.ts +80 -0
- package/src/ui/widgets/changelog-dialog.ts +196 -0
- package/src/ui/widgets/compact-dialog.ts +29 -0
- package/src/ui/widgets/confirm-dialog.ts +98 -0
- package/src/ui/widgets/export-dialog.ts +62 -0
- package/src/ui/widgets/footer.ts +117 -0
- package/src/ui/widgets/fork-dialog.ts +28 -0
- package/src/ui/widgets/help-overlay.ts +220 -0
- package/src/ui/widgets/import-dialog.ts +32 -0
- package/src/ui/widgets/input-dialog.ts +136 -0
- package/src/ui/widgets/label-dialog.ts +28 -0
- package/src/ui/widgets/new-session-dialog.ts +29 -0
- package/src/ui/widgets/prompt-bar.ts +92 -0
- package/src/ui/widgets/rename-dialog.ts +29 -0
- package/src/ui/widgets/restore-dialog.ts +60 -0
- package/src/ui/widgets/search-bar.ts +65 -0
- package/src/ui/widgets/select-dialog.ts +193 -0
- package/src/ui/widgets/session-info-dialog.ts +189 -0
- package/src/ui/widgets/tree-dialog.ts +293 -0
- package/src/utils/format.ts +83 -0
- package/src/utils/paths.ts +33 -0
package/src/ui/app.ts
ADDED
|
@@ -0,0 +1,2892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root panel component.
|
|
3
|
+
*
|
|
4
|
+
* Owns the three panes, focus state, key dispatch and the footer / search bar.
|
|
5
|
+
* Opened from src/index.ts via `ctx.ui.custom(...)`.
|
|
6
|
+
*
|
|
7
|
+
* Layout (see docs/design.md):
|
|
8
|
+
*
|
|
9
|
+
* 25% 75%
|
|
10
|
+
* ┌──────────────┬────────────────────┐
|
|
11
|
+
* │ [1] SESSIONS │ │
|
|
12
|
+
* ├──────────────┤ [3] CONTENT │
|
|
13
|
+
* │ [2] TREE │ │
|
|
14
|
+
* └──────────────┴────────────────────┘
|
|
15
|
+
* │ NORMAL │ / Search ? Help ... │ <- footer, or the search bar in search mode
|
|
16
|
+
*
|
|
17
|
+
* This layer does no I/O: all data arrives through the injected `DataSource`
|
|
18
|
+
* so the panel stays testable with plain objects.
|
|
19
|
+
*
|
|
20
|
+
* Key handling (本任务范围):
|
|
21
|
+
* - 按键 → resolveKeys(bindings, focus, pending) → ActionId → dispatch()
|
|
22
|
+
* - 支持多键序列("gg"):前缀匹配时把按键放进 pending 缓冲,等待下一键
|
|
23
|
+
* - h / l 前后切换焦点,1 / 2 / 3 直接跳到对应面板(面板标题显示 "[1] SESSIONS")
|
|
24
|
+
* - C 切到 Current folder,A 切到 All(各自只做单向切换),? 帮助
|
|
25
|
+
* - / 搜索(lazygit 风格,只作用于当前聚焦的面板,每个面板各记各的关键字):底部出现搜索栏,输入时实时跳到
|
|
26
|
+
* 原位置之后的第一个匹配,Enter 保留关键字退出输入栏,Esc 清掉关键字并回到原位置;之后 n / N 在匹配之间
|
|
27
|
+
* 往下 / 往上跳并回绕(搜索生效期间 n / N 优先于面板自己的同键绑定),normal 模式下 Esc 清掉当前面板的搜索。
|
|
28
|
+
* 列表不过滤只跳转:SESSIONS 按 名称 / 预览(模型 / 路径只通过 model: / path:,after: / before: 限定时间),
|
|
29
|
+
* TREE 按 label / 正文(tag: / role: 限定;目标藏在折叠段里时展开它的祖先,右侧跟着高亮),CONTENT 按渲染后的
|
|
30
|
+
* 正文行(只有自由文本,跳转把该行滚到面板顶部)。命中的文字高亮、当前匹配加强调,标题右侧显示 2/7 matches
|
|
31
|
+
* - j/k、gg/G:SESSIONS / TREE 移动光标,CONTENT 按行滚动;SESSIONS 里 J/K 滚动右侧内容
|
|
32
|
+
* - SESSIONS 光标变化 → 重新加载 TREE + CONTENT;TREE 光标变化 → CONTENT 高亮并滚到对应消息
|
|
33
|
+
* - TREE:y 复制节点全文(走注入的 ActionSource),T 居中弹出 Label 输入框(类似 lazygit 的 commit 弹窗),回车保存 / Esc 取消 / 空值清除;
|
|
34
|
+
* z 折叠 / 展开光标所在的分支段(旁支默认折叠、活动分支展开,段内按 z 折叠所在段并跳到段头);
|
|
35
|
+
* a 打开完整树对话框(顶部搜索框、中间完整树、底部提示;和小面板共用折叠状态)。小面板不做 d/t/u/l/a 过滤
|
|
36
|
+
* - 树对话框里的按键按 tree-dialog scope 解析(对话框自己的键 → tree 面板的键 → global):j/k/gg/G 移动、y / T / Enter 和面板一样但作用于
|
|
37
|
+
* 对话框光标、z 折叠、d/t/u/l/a 过滤(重新加载树,面板同步)、/ 聚焦顶部搜索框实时过滤(Esc 退出搜索框但关键字和结果保留、再按 / 接着改,
|
|
38
|
+
* Enter 在搜索框里没有含义;搜索期间折叠全部打开,删光关键字或关对话框后恢复)、列表上 q / Esc 关闭并让面板光标跳到对话框选中的行
|
|
39
|
+
* (藏在折叠段里就展开它);? 在对话框里关掉,它的键都在底部一行
|
|
40
|
+
* - Enter:SESSIONS 里切到光标所在会话(/resume);TREE 里以光标节点为叶子恢复(/tree restore):先居中弹出
|
|
41
|
+
* Summarize branch? 三选菜单(No summary / Summarize / Summarize with custom prompt,自定义指令再弹一个输入框),
|
|
42
|
+
* 光标就在活动叶子上或 pi 设置了 branchSummary.skipPrompt 时不问、直接进入;
|
|
43
|
+
* 成功后关闭面板,失败原因留在 footer 里、面板不关;等待 pi 切换 / 写摘要期间面板隐藏且不响应按键
|
|
44
|
+
* - SESSIONS:d 删除(先弹 Yes / No 确认框,默认停在 No,y / n 直接选;当前打开的会话拒绝删除;删完重新拉列表、光标夹回范围内),
|
|
45
|
+
* r 重命名(居中输入框,预填当前名字,空值清除;改完重新拉列表、光标留在同一会话上),
|
|
46
|
+
* s 循环切换排序(recent → created → title → threaded,光标跟着同一会话走),i 会话信息弹窗(y 复制全部内容,Esc 关闭)
|
|
47
|
+
* - space 多选:d 在有选中时批量删除(跳过当前会话,失败的留在列表和选中里),选中多个时 r / o / y / e / S 拒绝;
|
|
48
|
+
* Esc 先清空选中再退出。@(global)打开 pi 的 changelog 弹窗(j/k 滚动,Esc / q / @ 关闭)
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
52
|
+
import type { Component, Focusable, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
53
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
54
|
+
import { type Binding, compileKeymap, labelsFor, labelsForFocus, matchesKeyId, resolveKeys } from "../config/keys.ts";
|
|
55
|
+
import { DEFAULT_KEYMAP, FOCUS_ACTIONS, isDisabledIn, paneTitleText, TREE_DIALOG_FOOTER, treeDialogHintText } from "../config/keymap.ts";
|
|
56
|
+
import { LEFT_COLUMN_RATIO, PANE_IDS, SESSION_SORT_MODES, SPINNER_INTERVAL_MS, TREE_DIALOG_SCOPE } from "../constants.ts";
|
|
57
|
+
import { t } from "../i18n/index.ts";
|
|
58
|
+
import { highlightTerms, matchesTokens, matchSessionRow, matchTreeRow, parseSearchQuery, searchTokens } from "../data/search.ts";
|
|
59
|
+
import { findSessionIndex } from "../data/sessions.ts";
|
|
60
|
+
import {
|
|
61
|
+
applyTreeFold,
|
|
62
|
+
defaultFolded,
|
|
63
|
+
filterTreeRows,
|
|
64
|
+
foldedAncestors,
|
|
65
|
+
foldTarget,
|
|
66
|
+
nearestListedIndex,
|
|
67
|
+
} from "../data/tree-fold.ts";
|
|
68
|
+
import type {
|
|
69
|
+
ActionId,
|
|
70
|
+
ContentBlock,
|
|
71
|
+
DeleteMethod,
|
|
72
|
+
EnterOutcome,
|
|
73
|
+
ExportFormat,
|
|
74
|
+
ExportTarget,
|
|
75
|
+
ForkPoint,
|
|
76
|
+
KeyHint,
|
|
77
|
+
Keymap,
|
|
78
|
+
ListScope,
|
|
79
|
+
PaneId,
|
|
80
|
+
PanelMode,
|
|
81
|
+
PaneSearch,
|
|
82
|
+
RestoreOptions,
|
|
83
|
+
SearchView,
|
|
84
|
+
SessionInfo,
|
|
85
|
+
SessionRow,
|
|
86
|
+
SessionSortMode,
|
|
87
|
+
ShareResult,
|
|
88
|
+
TreeFilter,
|
|
89
|
+
TreeRow,
|
|
90
|
+
} from "../types.ts";
|
|
91
|
+
import { fit, sideBySide } from "./frame.ts";
|
|
92
|
+
import { hitTest, listVisibleRows, type MouseTarget, panelGeometry } from "./mouse.ts";
|
|
93
|
+
import { type ContentLayout, layoutContent, maxScroll, renderContentPane } from "./panes/content-pane.ts";
|
|
94
|
+
import { clampFirst, renderSessionsPane, scrollOffset } from "./panes/sessions-pane.ts";
|
|
95
|
+
import { renderTreePane } from "./panes/tree-pane.ts";
|
|
96
|
+
import { type OutlinePrefix, treeOutline } from "./tree-outline.ts";
|
|
97
|
+
import { ChangelogDialog } from "./widgets/changelog-dialog.ts";
|
|
98
|
+
import { compactDialogHints, compactDialogTitle } from "./widgets/compact-dialog.ts";
|
|
99
|
+
import {
|
|
100
|
+
cloneSessionTitle,
|
|
101
|
+
confirmDialogSpec,
|
|
102
|
+
deleteSessionsTitle,
|
|
103
|
+
deleteSessionTitle,
|
|
104
|
+
forkSessionTitle,
|
|
105
|
+
importSessionTitle,
|
|
106
|
+
overwriteFileTitle,
|
|
107
|
+
shareSessionTitle,
|
|
108
|
+
} from "./widgets/confirm-dialog.ts";
|
|
109
|
+
import { EXPORT_FORMAT_ORDER, exportFormatHints, exportFormats, exportFormatTitle, exportPathHints, exportPathTitle } from "./widgets/export-dialog.ts";
|
|
110
|
+
import { renderFooter } from "./widgets/footer.ts";
|
|
111
|
+
import { forkDialogHints, forkDialogTitle } from "./widgets/fork-dialog.ts";
|
|
112
|
+
import { compactKeys, helpLineCount, overlayHelp } from "./widgets/help-overlay.ts";
|
|
113
|
+
import { importDialogHints, importDialogSubject, importDialogTitle } from "./widgets/import-dialog.ts";
|
|
114
|
+
import { InputDialog } from "./widgets/input-dialog.ts";
|
|
115
|
+
import { labelDialogHints, labelDialogTitle } from "./widgets/label-dialog.ts";
|
|
116
|
+
import { newSessionDialogHints, newSessionDialogTitle } from "./widgets/new-session-dialog.ts";
|
|
117
|
+
import { renameDialogHints, renameDialogTitle } from "./widgets/rename-dialog.ts";
|
|
118
|
+
import {
|
|
119
|
+
customPromptHints,
|
|
120
|
+
CUSTOM_PROMPT_INDEX,
|
|
121
|
+
customPromptTitle,
|
|
122
|
+
SUMMARY_CHOICES,
|
|
123
|
+
summaryMenu,
|
|
124
|
+
summaryMenuHints,
|
|
125
|
+
summaryMenuTitle,
|
|
126
|
+
} from "./widgets/restore-dialog.ts";
|
|
127
|
+
import { renderSearchStatus, SearchBar } from "./widgets/search-bar.ts";
|
|
128
|
+
import { SelectDialog } from "./widgets/select-dialog.ts";
|
|
129
|
+
import { SessionInfoDialog } from "./widgets/session-info-dialog.ts";
|
|
130
|
+
import { TreeDialog } from "./widgets/tree-dialog.ts";
|
|
131
|
+
|
|
132
|
+
/** Mutable UI state of the panel. Kept in one place for easy debugging. */
|
|
133
|
+
export interface PanelState {
|
|
134
|
+
focus: PaneId;
|
|
135
|
+
mode: PanelMode;
|
|
136
|
+
/** Index of the highlighted row per list pane (for content: first visible body line). */
|
|
137
|
+
cursor: Record<PaneId, number>;
|
|
138
|
+
/** entryId of the content block highlighted by the tree cursor. */
|
|
139
|
+
contentHighlight: string | undefined;
|
|
140
|
+
/** Sessions selected with <space> for batch operations. */
|
|
141
|
+
selectedSessionFiles: Set<string>;
|
|
142
|
+
/**
|
|
143
|
+
* Active `/` search per pane (absent = none). Kept per pane, so switching
|
|
144
|
+
* panes keeps each pane's query; only the focused pane's search is acted on.
|
|
145
|
+
*/
|
|
146
|
+
search: Partial<Record<PaneId, PaneSearch>>;
|
|
147
|
+
scope: ListScope;
|
|
148
|
+
sort: SessionSortMode;
|
|
149
|
+
/** Tree filter, chosen with d/t/u/l/a in the tree dialog; the pane lists the same filtered tree. */
|
|
150
|
+
treeFilter: TreeFilter;
|
|
151
|
+
/**
|
|
152
|
+
* Folded tree rows (branch-segment heads whose descendants are hidden, see
|
|
153
|
+
* data/tree-fold.ts). Reset to "side branches folded" whenever another
|
|
154
|
+
* session is loaded; kept across reloads of the same session.
|
|
155
|
+
*/
|
|
156
|
+
treeFolded: Set<string>;
|
|
157
|
+
/** Whether the `?` overlay is open, and its scroll offset. */
|
|
158
|
+
helpOpen: boolean;
|
|
159
|
+
helpScroll: number;
|
|
160
|
+
/**
|
|
161
|
+
* Wheel-scroll offset (first visible row) for the two list panes; null means
|
|
162
|
+
* "follow the cursor" (the keyboard default that centers the cursor). The
|
|
163
|
+
* wheel sets a number to scroll the view without moving the selection; any
|
|
164
|
+
* cursor move clears it back to null.
|
|
165
|
+
*/
|
|
166
|
+
listScroll: { sessions: number | null; tree: number | null };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function createInitialState(overrides: Partial<PanelState> = {}): PanelState {
|
|
170
|
+
return {
|
|
171
|
+
focus: "sessions",
|
|
172
|
+
mode: "normal",
|
|
173
|
+
cursor: { sessions: 0, tree: 0, content: 0 },
|
|
174
|
+
contentHighlight: undefined,
|
|
175
|
+
selectedSessionFiles: new Set(),
|
|
176
|
+
search: {},
|
|
177
|
+
scope: "current-folder",
|
|
178
|
+
sort: "recent",
|
|
179
|
+
treeFilter: "default",
|
|
180
|
+
treeFolded: new Set(),
|
|
181
|
+
helpOpen: false,
|
|
182
|
+
helpScroll: 0,
|
|
183
|
+
listScroll: { sessions: null, tree: null },
|
|
184
|
+
...overrides,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Async loaders injected by the entry point (they wrap src/data/*). */
|
|
189
|
+
export interface DataSource {
|
|
190
|
+
listSessions(scope: ListScope, sort: SessionSortMode): Promise<SessionRow[]>;
|
|
191
|
+
loadTree(sessionFile: string, filter: TreeFilter): Promise<TreeRow[]>;
|
|
192
|
+
loadContent(sessionFile: string, leafEntryId?: string): Promise<ContentBlock[]>;
|
|
193
|
+
/** `i` in SESSIONS: what /session shows; undefined when the file cannot be read. */
|
|
194
|
+
loadSessionInfo?(sessionFile: string): Promise<SessionInfo | undefined>;
|
|
195
|
+
/** `o` in SESSIONS: the user messages the fork selector lists (empty = nothing to fork). */
|
|
196
|
+
loadForkPoints?(sessionFile: string): Promise<ForkPoint[]>;
|
|
197
|
+
/** `@`: pi's changelog as markdown (what /changelog shows). */
|
|
198
|
+
loadChangelog?(): Promise<string>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Side effects injected by the entry point (they wrap src/actions/*).
|
|
203
|
+
* 面板本身不做 I/O:复制、打标签、恢复会话、删除、改名都通过这里交给 actions 层。
|
|
204
|
+
*/
|
|
205
|
+
export interface ActionSource {
|
|
206
|
+
/** Copy the node's full text to the clipboard; `false` = the entry has no text. */
|
|
207
|
+
copyNodeText(sessionFile: string, entryId: string): Promise<boolean>;
|
|
208
|
+
/** Set, or clear with `undefined`, the label of a node. */
|
|
209
|
+
setNodeLabel(sessionFile: string, entryId: string, label: string | undefined): Promise<void>;
|
|
210
|
+
/** Enter in SESSIONS: make pi show this session (/resume). Rejects with the reason on failure. */
|
|
211
|
+
resumeSession(sessionFile: string): Promise<EnterOutcome>;
|
|
212
|
+
/**
|
|
213
|
+
* Enter in TREE: continue the conversation from this node (/tree restore),
|
|
214
|
+
* switching session first if needed; `options` is the summary choice.
|
|
215
|
+
*/
|
|
216
|
+
restoreNode(sessionFile: string, entryId: string, options: RestoreOptions): Promise<EnterOutcome>;
|
|
217
|
+
/** d in SESSIONS (after confirmation): remove the file; resolves to how it was removed. */
|
|
218
|
+
deleteSession?(sessionFile: string): Promise<DeleteMethod>;
|
|
219
|
+
/** r in SESSIONS: set the display name ("" clears it). */
|
|
220
|
+
renameSession?(sessionFile: string, name: string): Promise<void>;
|
|
221
|
+
/** n in SESSIONS: start a fresh session, naming it when `name` is non-empty (/new). */
|
|
222
|
+
newSession?(name: string): Promise<EnterOutcome>;
|
|
223
|
+
/** o in SESSIONS (after picking a message and confirming): fork before that user message and open the fork (/fork). */
|
|
224
|
+
forkSession?(sessionFile: string, entryId: string): Promise<EnterOutcome>;
|
|
225
|
+
/** y in SESSIONS (after confirmation): clone the active branch to a new file (/clone). */
|
|
226
|
+
cloneSession?(sessionFile: string): Promise<EnterOutcome>;
|
|
227
|
+
/** c in SESSIONS: compact this conversation's active branch and open it (/compact). */
|
|
228
|
+
compactSession?(sessionFile: string, customInstructions?: string): Promise<EnterOutcome>;
|
|
229
|
+
/** Y in SESSIONS: copy the last assistant reply to the clipboard; `false` = no reply yet. */
|
|
230
|
+
copyLastReply?(sessionFile: string): Promise<boolean>;
|
|
231
|
+
/** y in the Session Info dialog: copy its text to the clipboard. */
|
|
232
|
+
copyText?(text: string): Promise<void>;
|
|
233
|
+
/** e in SESSIONS: where an export goes for what the user typed ("" = pi's default path); synchronous, no writing. */
|
|
234
|
+
exportTarget?(sessionFile: string, format: ExportFormat, input: string): ExportTarget;
|
|
235
|
+
/** e in SESSIONS (once the path is picked, and confirmed when it exists): write the export, resolving to its path (/export). */
|
|
236
|
+
exportSession?(sessionFile: string, format: ExportFormat, outputPath: string): Promise<string>;
|
|
237
|
+
/** I in SESSIONS (after confirmation): copy a session JSONL into the session folder and switch to it (/import). */
|
|
238
|
+
importSession?(input: string): Promise<EnterOutcome>;
|
|
239
|
+
/** S in SESSIONS (after confirmation): upload as a secret GitHub gist (/share). */
|
|
240
|
+
shareSession?(sessionFile: string): Promise<ShareResult>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface LazyPanelOptions {
|
|
244
|
+
theme: Theme;
|
|
245
|
+
data: DataSource;
|
|
246
|
+
/** Optional: without it y / T / Enter report that actions are unavailable. */
|
|
247
|
+
actions?: ActionSource;
|
|
248
|
+
/** Terminal height available to the panel, re-read on every render. */
|
|
249
|
+
getHeight: () => number;
|
|
250
|
+
requestRender: () => void;
|
|
251
|
+
onClose: () => void;
|
|
252
|
+
/**
|
|
253
|
+
* Temporarily hide / show the panel while pi switches sessions, so prompts pi
|
|
254
|
+
* raises meanwhile (e.g. "session cwd not found") are visible and get the keys.
|
|
255
|
+
*/
|
|
256
|
+
setHidden?: (hidden: boolean) => void;
|
|
257
|
+
/** Resolved keymap (defaults deep-merged with the user file). */
|
|
258
|
+
keymap?: Keymap;
|
|
259
|
+
initialState?: Partial<PanelState>;
|
|
260
|
+
leftColumnRatio?: number;
|
|
261
|
+
/** Initial footer status, e.g. config warnings. */
|
|
262
|
+
status?: string;
|
|
263
|
+
/** Extension version, shown muted at the far right of the footer. */
|
|
264
|
+
version?: string;
|
|
265
|
+
/** pi's `branchSummary.skipPrompt`: TREE Enter restores without asking (no summary). */
|
|
266
|
+
skipSummaryPrompt?: boolean;
|
|
267
|
+
/**
|
|
268
|
+
* File of the session pi currently has open. On the first load the SESSIONS
|
|
269
|
+
* cursor starts on it (a brand-new session is not listed yet, so the cursor
|
|
270
|
+
* stays on the first row); `d` refuses to delete it, like pi's /resume.
|
|
271
|
+
*/
|
|
272
|
+
currentSessionFile?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Max time between keys of a multi-key sequence such as "gg". */
|
|
276
|
+
const PENDING_TIMEOUT_MS = 1000;
|
|
277
|
+
|
|
278
|
+
/** Delay before (re)loading the session under the cursor while the user is still moving. */
|
|
279
|
+
const SESSION_LOAD_DEBOUNCE_MS = 40;
|
|
280
|
+
|
|
281
|
+
/** Node TREE Enter is restoring to while its menu / custom prompt is open. */
|
|
282
|
+
interface RestoreTarget {
|
|
283
|
+
file: string;
|
|
284
|
+
entryId: string;
|
|
285
|
+
/** "role: text" of the node, shown in the dialog title bars. */
|
|
286
|
+
subject: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A tree row plus the session it belongs to: what y / T / Enter act on (the pane's cursor row, or the dialog's). */
|
|
290
|
+
interface TreeTarget {
|
|
291
|
+
file: string;
|
|
292
|
+
row: TreeRow;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Where the focused pane was when `/` opened the search bar: the live search
|
|
297
|
+
* looks for the first match from here, Esc in the bar comes back here.
|
|
298
|
+
*/
|
|
299
|
+
interface SearchOrigin {
|
|
300
|
+
pane: PaneId;
|
|
301
|
+
/** Cursor row of the sessions list, row index in the whole tree, or first visible content line. */
|
|
302
|
+
index: number;
|
|
303
|
+
/** Tree pane: the row itself and the folds as they were (jumping to a match unfolds branches). */
|
|
304
|
+
entryId?: string;
|
|
305
|
+
folded?: Set<string>;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export class LazyPanel implements Component, Focusable {
|
|
309
|
+
readonly state: PanelState;
|
|
310
|
+
readonly keymap: Keymap;
|
|
311
|
+
private readonly bindings: Binding[];
|
|
312
|
+
private sessions: SessionRow[] = [];
|
|
313
|
+
/** Whole (filtered) tree of the loaded session; `visibleTree` is what the pane lists once folded branches are hidden. */
|
|
314
|
+
private tree: TreeRow[] = [];
|
|
315
|
+
private visibleTree: TreeRow[] = [];
|
|
316
|
+
/** Outline prefixes of `tree` for the pane, recomputed together with `visibleTree`. */
|
|
317
|
+
private treeOutline: ReadonlyMap<string, OutlinePrefix> = new Map();
|
|
318
|
+
private content: ContentBlock[] = [];
|
|
319
|
+
/** Leaf entry the current `content` branch ends at (undefined = session's own leaf). */
|
|
320
|
+
private contentLeaf: string | undefined;
|
|
321
|
+
private status: string | undefined;
|
|
322
|
+
private loadedSessionFile: string | undefined;
|
|
323
|
+
/** Session to put the cursor on at the first load (see `LazyPanelOptions.currentSessionFile`); cleared once used. */
|
|
324
|
+
private locateSessionFile: string | undefined;
|
|
325
|
+
/** Session pi has open (see `LazyPanelOptions.currentSessionFile`): the one `d` must not delete. */
|
|
326
|
+
private readonly currentSessionFile: string | undefined;
|
|
327
|
+
private disposed = false;
|
|
328
|
+
private readonly ratio: number;
|
|
329
|
+
private readonly searchBar: SearchBar;
|
|
330
|
+
/** Shared centered text prompt: labelling a node, the custom summary instructions, renaming a session. */
|
|
331
|
+
private readonly inputDialog: InputDialog;
|
|
332
|
+
/** Shared centered menu: the "Summarize branch?" choice and the delete confirmation. */
|
|
333
|
+
private readonly selectDialog: SelectDialog;
|
|
334
|
+
/** Full tree in a big box (`a` in the tree pane) with its own cursor, live search and the filter keys. */
|
|
335
|
+
private readonly treeDialog: TreeDialog;
|
|
336
|
+
/** `i` in SESSIONS: the read-only Session Info box. */
|
|
337
|
+
private readonly infoDialog: SessionInfoDialog;
|
|
338
|
+
/** `@`: pi's changelog in a big scrollable box. */
|
|
339
|
+
private readonly changelogDialog: ChangelogDialog;
|
|
340
|
+
/** Cached changelog markdown so a second `@` opens instantly (only the first render is slow). */
|
|
341
|
+
private changelogMd: string | undefined;
|
|
342
|
+
/** Ticker that rotates the changelog loading spinner while the markdown is fetched / rendered. */
|
|
343
|
+
private changelogSpinner: ReturnType<typeof setInterval> | undefined;
|
|
344
|
+
/**
|
|
345
|
+
* Fold state from before the dialog's search started: a search shows every
|
|
346
|
+
* match, so folds are cleared meanwhile and restored when the query is gone.
|
|
347
|
+
*/
|
|
348
|
+
private foldedBeforeSearch: Set<string> | undefined;
|
|
349
|
+
/** Node being labelled while `mode === "label"`. */
|
|
350
|
+
private labelTarget: { file: string; entryId: string } | undefined;
|
|
351
|
+
/** Node being restored to while `mode === "restore"`. */
|
|
352
|
+
private restoreTarget: RestoreTarget | undefined;
|
|
353
|
+
/** Session being renamed while `mode === "rename"`. */
|
|
354
|
+
private renameTarget: SessionRow | undefined;
|
|
355
|
+
/** Session the delete confirmation is about while `mode === "confirm"`. */
|
|
356
|
+
private deleteTarget: SessionRow | undefined;
|
|
357
|
+
/** Sessions the batch delete confirmation is about (d with a multi-selection). */
|
|
358
|
+
private batchDeleteTargets: SessionRow[] | undefined;
|
|
359
|
+
/** Session being forked while `mode === "fork"`: its file, title-bar subject and the user messages to pick from. */
|
|
360
|
+
private forkTarget: { file: string; subject: string; points: ForkPoint[] } | undefined;
|
|
361
|
+
/** Session the clone confirmation is about while `mode === "clone"`. */
|
|
362
|
+
private cloneTarget: SessionRow | undefined;
|
|
363
|
+
/** Session being compacted while `mode === "compact"`. */
|
|
364
|
+
private compactTarget: SessionRow | undefined;
|
|
365
|
+
/** Session being exported while `mode === "export"`: its file, title-bar subject, and the format once picked. */
|
|
366
|
+
private exportJob: { file: string; subject: string; format?: ExportFormat } | undefined;
|
|
367
|
+
/** True while an Enter action is waiting for pi (keys are ignored, the panel is hidden). */
|
|
368
|
+
private entering = false;
|
|
369
|
+
/** Raw key chunks of an unfinished multi-key sequence. */
|
|
370
|
+
private pending: string[] = [];
|
|
371
|
+
private pendingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
372
|
+
private _focused = false;
|
|
373
|
+
/** Debounced reload of tree + content after the sessions cursor moved. */
|
|
374
|
+
private sessionLoadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
375
|
+
private sessionLoadPromise: Promise<void> | undefined;
|
|
376
|
+
private sessionLoadResolve: (() => void) | undefined;
|
|
377
|
+
/** Content layout cache keyed by blocks identity / width / highlight. */
|
|
378
|
+
private layoutCache: { blocks: ContentBlock[]; inner: number; highlight: string | undefined; layout: ContentLayout } | undefined;
|
|
379
|
+
/** Viewport of the content pane as of the last render, used to clamp scrolling. */
|
|
380
|
+
private contentView = { inner: 60, visible: 10 };
|
|
381
|
+
/** Terminal width from the last render, used to size the help overlay when clamping its scroll. */
|
|
382
|
+
private lastWidth = 80;
|
|
383
|
+
/** Matching body lines of the content pane, per layout and query (the layout changes with the width, so the matches follow it). */
|
|
384
|
+
private contentSearchCache: { layout: ContentLayout; query: string; matches: number[] } | undefined;
|
|
385
|
+
/** Set while the search bar is open (`mode === "search"`). */
|
|
386
|
+
private searchOrigin: SearchOrigin | undefined;
|
|
387
|
+
|
|
388
|
+
constructor(private readonly o: LazyPanelOptions) {
|
|
389
|
+
this.state = createInitialState(o.initialState);
|
|
390
|
+
this.keymap = o.keymap ?? DEFAULT_KEYMAP;
|
|
391
|
+
this.bindings = compileKeymap(this.keymap);
|
|
392
|
+
this.ratio = o.leftColumnRatio ?? LEFT_COLUMN_RATIO;
|
|
393
|
+
this.status = o.status;
|
|
394
|
+
this.locateSessionFile = o.currentSessionFile;
|
|
395
|
+
this.currentSessionFile = o.currentSessionFile;
|
|
396
|
+
this.searchBar = new SearchBar({
|
|
397
|
+
theme: o.theme,
|
|
398
|
+
onSubmit: (q) => this.submitSearch(q),
|
|
399
|
+
onCancel: () => this.cancelSearch(),
|
|
400
|
+
// 每敲一个键就实时搜索(Enter / Esc 的回调先于这里触发,那时已经退出搜索模式)。
|
|
401
|
+
onChange: () => this.onSearchInput(),
|
|
402
|
+
});
|
|
403
|
+
this.inputDialog = new InputDialog({
|
|
404
|
+
theme: o.theme,
|
|
405
|
+
onChange: () => this.o.requestRender(),
|
|
406
|
+
});
|
|
407
|
+
this.selectDialog = new SelectDialog({
|
|
408
|
+
theme: o.theme,
|
|
409
|
+
onChange: () => this.o.requestRender(),
|
|
410
|
+
});
|
|
411
|
+
this.treeDialog = new TreeDialog({
|
|
412
|
+
theme: o.theme,
|
|
413
|
+
onChange: () => this.o.requestRender(),
|
|
414
|
+
});
|
|
415
|
+
this.infoDialog = new SessionInfoDialog({ theme: o.theme });
|
|
416
|
+
this.changelogDialog = new ChangelogDialog({ theme: o.theme, onClose: () => this.closeChangelog() });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Focusable: forwarded to the active prompt so the IME cursor lands in the bar. */
|
|
420
|
+
get focused(): boolean {
|
|
421
|
+
return this._focused;
|
|
422
|
+
}
|
|
423
|
+
set focused(v: boolean) {
|
|
424
|
+
this._focused = v;
|
|
425
|
+
this.searchBar.focused = v && this.state.mode === "search";
|
|
426
|
+
this.inputDialog.focused = v && this.inputDialog.isOpen;
|
|
427
|
+
this.treeDialog.focused = v && this.treeDialog.isOpen;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
// Data loading
|
|
432
|
+
// -----------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
/** Load sessions, then the tree + content for the cursor session. */
|
|
435
|
+
async load(): Promise<void> {
|
|
436
|
+
this.setStatus(t("status.loadingSessions"));
|
|
437
|
+
// 首次加载:光标落到 pi 当前打开的会话上(新会话还没列出来时就留在第一行)。
|
|
438
|
+
// 之后 C / A 切范围重新加载时不再定位,光标照旧回到顶部。
|
|
439
|
+
const keep = this.locateSessionFile;
|
|
440
|
+
this.locateSessionFile = undefined;
|
|
441
|
+
if (await this.listSessions(keep)) this.setStatus(undefined);
|
|
442
|
+
await this.loadSelectedSession();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Re-read the sessions list and put the cursor on `keepFile` (or clamp it),
|
|
447
|
+
* recomputing the pane's search matches; the tree + content follow only if
|
|
448
|
+
* the session under the cursor changed. Resolves to false when the listing
|
|
449
|
+
* failed (reported in the footer, the old rows stay).
|
|
450
|
+
*
|
|
451
|
+
* 删除 / 改名 / 换排序之后重新拉列表:光标尽量留在同一个会话上,只有光标下的会话
|
|
452
|
+
* 真的变了(比如删掉的那个)才重新加载 TREE / CONTENT。
|
|
453
|
+
*/
|
|
454
|
+
private async listSessions(keepFile: string | undefined): Promise<boolean> {
|
|
455
|
+
try {
|
|
456
|
+
const rows = await this.o.data.listSessions(this.state.scope, this.state.sort);
|
|
457
|
+
if (this.disposed) return false;
|
|
458
|
+
this.sessions = rows;
|
|
459
|
+
} catch (err) {
|
|
460
|
+
this.setStatus(t("status.listFailed", { error: (err as Error).message }));
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
// 多选里已经不在列表中的会话(被删掉、换了范围)一并去掉。
|
|
464
|
+
const listed = new Set(this.sessions.map((r) => r.file));
|
|
465
|
+
for (const file of this.state.selectedSessionFiles) if (!listed.has(file)) this.state.selectedSessionFiles.delete(file);
|
|
466
|
+
const idx = findSessionIndex(this.sessions, keepFile);
|
|
467
|
+
this.state.cursor.sessions = idx >= 0 ? idx : clamp(this.state.cursor.sessions, 0, Math.max(0, this.sessions.length - 1));
|
|
468
|
+
// 列表变了(首次加载、C / A 切范围、删除 / 改名 / 排序):SESSIONS 的搜索结果按新列表重算,关键字保留。
|
|
469
|
+
this.refreshSearch("sessions");
|
|
470
|
+
this.o.requestRender();
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** After the list changed: reload TREE + CONTENT when another session ended up under the cursor. */
|
|
475
|
+
private async followSessionsCursor(): Promise<void> {
|
|
476
|
+
if (this.sessions[this.state.cursor.sessions]?.file !== this.loadedSessionFile) await this.loadSelectedSession();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Reload tree and content for the session under the cursor. */
|
|
480
|
+
async loadSelectedSession(): Promise<void> {
|
|
481
|
+
const row = this.sessions[this.state.cursor.sessions];
|
|
482
|
+
if (!row) {
|
|
483
|
+
this.setTree([], new Set());
|
|
484
|
+
this.setContent([], undefined);
|
|
485
|
+
this.loadedSessionFile = undefined;
|
|
486
|
+
this.o.requestRender();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const file = row.file;
|
|
490
|
+
try {
|
|
491
|
+
const [tree, content] = await Promise.all([
|
|
492
|
+
this.o.data.loadTree(file, this.state.treeFilter),
|
|
493
|
+
this.o.data.loadContent(file),
|
|
494
|
+
]);
|
|
495
|
+
if (this.disposed) return;
|
|
496
|
+
// Ignore stale results if the cursor moved meanwhile.
|
|
497
|
+
if (this.sessions[this.state.cursor.sessions]?.file !== file) return;
|
|
498
|
+
// 换了会话:旁支折叠、活动分支展开(活动分支上的行因此一定可见)。
|
|
499
|
+
this.setTree(tree, defaultFolded(tree));
|
|
500
|
+
this.setContent(content, undefined);
|
|
501
|
+
this.loadedSessionFile = file;
|
|
502
|
+
// Put the tree cursor on the active leaf, like /tree does.
|
|
503
|
+
const leafIdx = findLastIndex(this.visibleTree, (r) => r.onActiveBranch);
|
|
504
|
+
this.state.cursor.tree = leafIdx >= 0 ? leafIdx : 0;
|
|
505
|
+
this.state.cursor.content = 0;
|
|
506
|
+
// 树光标落在活动叶子上,右侧内容同步滚到并高亮这条消息。
|
|
507
|
+
await this.syncContentToTree();
|
|
508
|
+
} catch (err) {
|
|
509
|
+
this.setTree([], new Set());
|
|
510
|
+
this.setContent([], undefined);
|
|
511
|
+
this.setStatus(t("status.openFailed", { error: (err as Error).message }));
|
|
512
|
+
}
|
|
513
|
+
this.o.requestRender();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Debounced `loadSelectedSession`: holding j/k in the sessions pane only
|
|
518
|
+
* opens the session the cursor finally rests on. Returns a promise that
|
|
519
|
+
* settles once that load has finished (handy for tests).
|
|
520
|
+
*/
|
|
521
|
+
scheduleSessionLoad(): Promise<void> {
|
|
522
|
+
if (this.sessionLoadTimer) clearTimeout(this.sessionLoadTimer);
|
|
523
|
+
if (!this.sessionLoadPromise) {
|
|
524
|
+
this.sessionLoadPromise = new Promise<void>((resolve) => {
|
|
525
|
+
this.sessionLoadResolve = resolve;
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// 只推迟定时器,复用同一个 promise:连续按 j 只会真正加载最后停下的那个会话。
|
|
529
|
+
this.sessionLoadTimer = setTimeout(() => {
|
|
530
|
+
const resolve = this.sessionLoadResolve;
|
|
531
|
+
this.sessionLoadTimer = undefined;
|
|
532
|
+
this.sessionLoadPromise = undefined;
|
|
533
|
+
this.sessionLoadResolve = undefined;
|
|
534
|
+
void this.loadSelectedSession().finally(() => resolve?.());
|
|
535
|
+
}, SESSION_LOAD_DEBOUNCE_MS);
|
|
536
|
+
return this.sessionLoadPromise;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private setContent(blocks: ContentBlock[], leaf: string | undefined): void {
|
|
540
|
+
this.content = blocks;
|
|
541
|
+
this.contentLeaf = leaf;
|
|
542
|
+
this.layoutCache = undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Replace the tree and its fold state, then refresh what the pane lists. */
|
|
546
|
+
private setTree(rows: TreeRow[], folded: Set<string>): void {
|
|
547
|
+
this.tree = rows;
|
|
548
|
+
this.state.treeFolded = folded;
|
|
549
|
+
this.refreshTreeView();
|
|
550
|
+
// 树换了(换会话、打标签、换过滤):TREE 的搜索结果按新树重算。
|
|
551
|
+
this.refreshSearch("tree");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** 折叠状态变了 / 树重新加载后:重新算可见行和大纲前缀(光标索引指向可见行)。 */
|
|
555
|
+
private refreshTreeView(): void {
|
|
556
|
+
this.visibleTree = applyTreeFold(this.tree, this.state.treeFolded);
|
|
557
|
+
this.treeOutline = treeOutline(this.tree, this.state.treeFolded);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Make the content pane follow the tree cursor.
|
|
562
|
+
*
|
|
563
|
+
* 选中的节点已经在当前显示的分支里 → 只高亮并滚动到那条消息;
|
|
564
|
+
* 节点在活动分支上但没有消息框(工具结果、空回复等)→ 保持显示完整活动分支,高亮它前面最近的一条消息;
|
|
565
|
+
* 节点在另一条分支上 → 重新加载“以该节点为叶子”的分支再高亮。
|
|
566
|
+
*/
|
|
567
|
+
private async syncContentToTree(): Promise<void> {
|
|
568
|
+
const node = this.visibleTree[this.state.cursor.tree];
|
|
569
|
+
const file = this.loadedSessionFile ?? this.sessions[this.state.cursor.sessions]?.file;
|
|
570
|
+
if (!node || !file) {
|
|
571
|
+
this.state.contentHighlight = undefined;
|
|
572
|
+
this.o.requestRender();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const shown = this.content.some((b) => b.entryId === node.entryId);
|
|
576
|
+
// 需要的分支:活动分支用 undefined(会话自己的叶子),否则以该节点为叶子。
|
|
577
|
+
const wantLeaf = node.onActiveBranch ? undefined : node.entryId;
|
|
578
|
+
if (!shown && this.contentLeaf !== wantLeaf) {
|
|
579
|
+
try {
|
|
580
|
+
const content = await this.o.data.loadContent(file, wantLeaf);
|
|
581
|
+
if (this.disposed) return;
|
|
582
|
+
// 光标又动了 / 会话换了:丢弃这次结果。
|
|
583
|
+
if (this.visibleTree[this.state.cursor.tree]?.entryId !== node.entryId || this.loadedSessionFile !== file) return;
|
|
584
|
+
this.setContent(content, wantLeaf);
|
|
585
|
+
} catch (err) {
|
|
586
|
+
this.setStatus(t("status.loadBranchFailed", { error: (err as Error).message }));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
this.highlightContent(node.entryId);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Highlight the block for `entryId` (or the nearest block before it in the tree) and scroll it to the top. */
|
|
594
|
+
private highlightContent(entryId: string): void {
|
|
595
|
+
const shown = new Set(this.content.map((b) => b.entryId));
|
|
596
|
+
let targetId: string | undefined = shown.has(entryId) ? entryId : undefined;
|
|
597
|
+
if (!targetId) {
|
|
598
|
+
// 没有对应消息块的节点:沿 tree 往上找最近的一条有消息块的节点。
|
|
599
|
+
for (let i = this.state.cursor.tree - 1; i >= 0 && !targetId; i--) {
|
|
600
|
+
const id = this.visibleTree[i]?.entryId;
|
|
601
|
+
if (id && shown.has(id)) targetId = id;
|
|
602
|
+
}
|
|
603
|
+
targetId ??= this.content[this.content.length - 1]?.entryId;
|
|
604
|
+
}
|
|
605
|
+
this.state.contentHighlight = targetId;
|
|
606
|
+
if (targetId) {
|
|
607
|
+
const start = this.contentLayout().starts.get(targetId) ?? 0;
|
|
608
|
+
this.state.cursor.content = Math.min(start, this.contentMaxScroll());
|
|
609
|
+
}
|
|
610
|
+
this.o.requestRender();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Cached layout of the current content for the last rendered width. */
|
|
614
|
+
private contentLayout(): ContentLayout {
|
|
615
|
+
const { inner } = this.contentView;
|
|
616
|
+
const highlight = this.state.contentHighlight;
|
|
617
|
+
const c = this.layoutCache;
|
|
618
|
+
if (c && c.blocks === this.content && c.inner === inner && c.highlight === highlight) return c.layout;
|
|
619
|
+
const layout = layoutContent(this.content, inner, this.o.theme, highlight);
|
|
620
|
+
this.layoutCache = { blocks: this.content, inner, highlight, layout };
|
|
621
|
+
return layout;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private contentMaxScroll(): number {
|
|
625
|
+
return maxScroll(this.contentLayout().lines.length, this.contentView.visible);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Remember the content viewport for this render (so keys can clamp against it) and return the layout. */
|
|
629
|
+
private contentLayoutFor(inner: number, visible: number): ContentLayout {
|
|
630
|
+
this.contentView = { inner: Math.max(1, inner), visible: Math.max(1, visible) };
|
|
631
|
+
const layout = this.contentLayout();
|
|
632
|
+
// 窗口变小后原来的滚动位置可能越界,这里顺手夹回来。
|
|
633
|
+
this.state.cursor.content = clamp(this.state.cursor.content, 0, maxScroll(layout.lines.length, this.contentView.visible));
|
|
634
|
+
return layout;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private setStatus(s: string | undefined): void {
|
|
638
|
+
this.status = s;
|
|
639
|
+
this.o.requestRender();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// -----------------------------------------------------------------------
|
|
643
|
+
// Input
|
|
644
|
+
// -----------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
handleInput(data: string): void {
|
|
647
|
+
if (this.disposed) return;
|
|
648
|
+
|
|
649
|
+
// 正在等 pi 切换会话 / 跳转节点:面板已隐藏,这期间的按键一律忽略,避免半途关掉面板。
|
|
650
|
+
if (this.entering) return;
|
|
651
|
+
|
|
652
|
+
// 搜索模式:所有按键交给输入框(Enter/Esc 由 SearchBar 回调处理)。
|
|
653
|
+
if (this.state.mode === "search") {
|
|
654
|
+
this.searchBar.handleInput(data);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 居中输入弹窗打开时(打标签、自定义摘要指令):同理全部交给弹窗。
|
|
659
|
+
if (this.inputDialog.isOpen) {
|
|
660
|
+
this.inputDialog.handleInput(data);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 居中选择菜单打开时(Summarize branch?、删除确认):j/k/Enter/Esc/y/n 都由菜单处理。
|
|
665
|
+
if (this.selectDialog.isOpen) {
|
|
666
|
+
this.selectDialog.handleInput(data);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 会话信息弹窗打开时只响应 y 复制 / Esc 关闭。
|
|
671
|
+
if (this.infoDialog.isOpen) {
|
|
672
|
+
this.infoDialog.handleInput(data);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// changelog 弹窗:滚动 / 关闭由弹窗处理,再按一次 @(用户绑定给 changelog 的键)也关闭。
|
|
677
|
+
if (this.changelogDialog.isOpen) {
|
|
678
|
+
if (this.isAction(data, "global", "changelog")) this.closeChangelog();
|
|
679
|
+
else this.changelogDialog.handleInput(data);
|
|
680
|
+
this.o.requestRender();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 帮助弹窗打开时只响应关闭 / 滚动。
|
|
685
|
+
if (this.state.helpOpen) {
|
|
686
|
+
this.handleHelpInput(data);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// 完整树对话框打开时:搜索框聚焦就全部交给输入框,否则按 tree-dialog scope 解析按键。
|
|
691
|
+
if (this.treeDialog.isOpen) {
|
|
692
|
+
this.handleTreeDialogInput(data);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Esc 优先:清掉半截序列或当前面板的搜索结果。
|
|
697
|
+
if (matchesKeyId(data, "escape")) {
|
|
698
|
+
if (this.pending.length) {
|
|
699
|
+
this.clearPending();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (this.state.search[this.state.focus]) {
|
|
703
|
+
this.clearSearch(this.state.focus);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
// SESSIONS 有多选时 Esc 先清空选中,再按一次才退出面板(lazygit 的做法)。
|
|
707
|
+
if (this.state.focus === "sessions" && this.state.selectedSessionFiles.size > 0) {
|
|
708
|
+
this.clearSelection();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
this.close();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// 搜索生效期间 n / N(global 的 search-next / search-prev)优先于面板自己的同键绑定
|
|
716
|
+
// (SESSIONS 里 n 本来是 new session),和 lazygit 搜索模式里的 n / N 一致。
|
|
717
|
+
if (this.pending.length === 0 && this.state.search[this.state.focus]) {
|
|
718
|
+
const g = resolveKeys(this.bindings, "global", [data]);
|
|
719
|
+
if (g.kind === "action" && (g.action === "search-next" || g.action === "search-prev")) {
|
|
720
|
+
this.dispatch(g.action);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const pressed = [...this.pending, data];
|
|
726
|
+
const result = resolveKeys(this.bindings, this.state.focus, pressed);
|
|
727
|
+
if (result.kind === "pending") {
|
|
728
|
+
this.pending = pressed;
|
|
729
|
+
this.armPendingTimer();
|
|
730
|
+
this.o.requestRender();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
this.clearPending();
|
|
734
|
+
if (result.kind === "action") this.dispatch(result.action);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private armPendingTimer(): void {
|
|
738
|
+
if (this.pendingTimer) clearTimeout(this.pendingTimer);
|
|
739
|
+
this.pendingTimer = setTimeout(() => {
|
|
740
|
+
this.pendingTimer = undefined;
|
|
741
|
+
if (this.pending.length) {
|
|
742
|
+
this.pending = [];
|
|
743
|
+
this.o.requestRender();
|
|
744
|
+
}
|
|
745
|
+
}, PENDING_TIMEOUT_MS);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private clearPending(): void {
|
|
749
|
+
this.pending = [];
|
|
750
|
+
if (this.pendingTimer) {
|
|
751
|
+
clearTimeout(this.pendingTimer);
|
|
752
|
+
this.pendingTimer = undefined;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private handleHelpInput(data: string): void {
|
|
757
|
+
const total = helpLineCount(this.keymap, this.state.focus, this.lastWidth);
|
|
758
|
+
// 关闭:Esc、q,或用户绑定给 help 的那个键(默认 ?)。
|
|
759
|
+
const closes = matchesKeyId(data, "escape") || matchesKeyId(data, "q") || this.isAction(data, "global", "help");
|
|
760
|
+
if (closes) {
|
|
761
|
+
this.state.helpOpen = false;
|
|
762
|
+
this.state.helpScroll = 0;
|
|
763
|
+
} else if (matchesKeyId(data, "j") || matchesKeyId(data, "down")) {
|
|
764
|
+
this.state.helpScroll = Math.min(this.state.helpScroll + 1, Math.max(0, total - 1));
|
|
765
|
+
} else if (matchesKeyId(data, "k") || matchesKeyId(data, "up")) {
|
|
766
|
+
this.state.helpScroll = Math.max(0, this.state.helpScroll - 1);
|
|
767
|
+
} else {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
this.o.requestRender();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Does a single raw key resolve to `action` in `scope`? */
|
|
774
|
+
private isAction(data: string, scope: "global" | PaneId, action: ActionId): boolean {
|
|
775
|
+
const r = resolveKeys(this.bindings, scope, [data]);
|
|
776
|
+
return r.kind === "action" && r.action === action;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Mode to return to when a label prompt / restore menu closes: `tree` while the dialog is still open. */
|
|
780
|
+
private baseMode(): PanelMode {
|
|
781
|
+
return this.treeDialog.isOpen ? "tree" : "normal";
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private openHelp(): void {
|
|
785
|
+
this.state.helpOpen = true;
|
|
786
|
+
this.state.helpScroll = 0;
|
|
787
|
+
this.o.requestRender();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// -----------------------------------------------------------------------
|
|
791
|
+
// Mouse (light adaptation; the keyboard stays primary)
|
|
792
|
+
// -----------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 鼠标只做三件事:滚轮 / 三指滚动指针下的面板;单击切焦点并选中列表项;
|
|
796
|
+
* 双击 SESSIONS 进入会话、双击 TREE 折叠 / 展开分支。press / drag / move 不处理,
|
|
797
|
+
* 交回终端做文本选择。注意:只有 pi 跑在 fullscreen TUI 模式下才会收到鼠标事件,
|
|
798
|
+
* regular(默认)模式由终端自己处理滚动 / 选择,见 docs/issues.md。
|
|
799
|
+
*/
|
|
800
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
801
|
+
if (this.disposed || this.entering) return undefined;
|
|
802
|
+
// 搜索输入 / 各类弹窗打开时:吞掉面板区域的滚轮和点击,避免误动下面的列表,但不做交互。
|
|
803
|
+
if (this.state.mode !== "normal" || this.anyDialogOpen()) {
|
|
804
|
+
return event.type === "wheel" || event.type === "click" ? { handled: true } : undefined;
|
|
805
|
+
}
|
|
806
|
+
if (event.type === "wheel") return this.handleWheel(event);
|
|
807
|
+
if (event.type === "click") return this.handleClick(event);
|
|
808
|
+
return undefined;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Any centered / full-screen overlay is up (its own input owns the keyboard). */
|
|
812
|
+
private anyDialogOpen(): boolean {
|
|
813
|
+
return (
|
|
814
|
+
this.state.helpOpen ||
|
|
815
|
+
this.inputDialog.isOpen ||
|
|
816
|
+
this.selectDialog.isOpen ||
|
|
817
|
+
this.infoDialog.isOpen ||
|
|
818
|
+
this.changelogDialog.isOpen ||
|
|
819
|
+
this.treeDialog.isOpen
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** Map an event to the pane / row under the pointer, using the same window offsets as render(). */
|
|
824
|
+
private hitTarget(event: TuiMouseEvent): MouseTarget | undefined {
|
|
825
|
+
const height = Math.max(8, this.o.getHeight());
|
|
826
|
+
const visible = listVisibleRows(height);
|
|
827
|
+
return hitTest({
|
|
828
|
+
width: event.width,
|
|
829
|
+
height,
|
|
830
|
+
ratio: this.ratio,
|
|
831
|
+
x: event.x,
|
|
832
|
+
y: event.y,
|
|
833
|
+
sessionsFirst: this.listFirst("sessions", visible.sessions),
|
|
834
|
+
sessionsTotal: this.sessions.length,
|
|
835
|
+
treeFirst: this.listFirst("tree", visible.tree),
|
|
836
|
+
treeTotal: this.visibleTree.length,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** First visible row of a list pane: the wheel offset when set, else the cursor-centered window; always clamped. */
|
|
841
|
+
private listFirst(pane: "sessions" | "tree", visible: number): number {
|
|
842
|
+
const total = pane === "sessions" ? this.sessions.length : this.visibleTree.length;
|
|
843
|
+
const override = this.state.listScroll[pane];
|
|
844
|
+
const raw = override ?? scrollOffset(this.state.cursor[pane], total, visible);
|
|
845
|
+
return clampFirst(raw, total, visible);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Wheel: scroll the pane under the pointer without changing focus or the selection (click owns those). */
|
|
849
|
+
private handleWheel(event: TuiMouseEvent): TuiMouseEventResult {
|
|
850
|
+
const delta = event.wheelDelta ?? 0;
|
|
851
|
+
if (delta === 0) return { handled: true };
|
|
852
|
+
const target = this.hitTarget(event);
|
|
853
|
+
if (target?.pane === "sessions") this.scrollList("sessions", delta);
|
|
854
|
+
else if (target?.pane === "tree") this.scrollList("tree", delta);
|
|
855
|
+
else if (target?.pane === "content") this.scrollContent(delta);
|
|
856
|
+
return { handled: true };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Scroll a list pane's viewport by `delta` rows, leaving the selection where it is (wheel only). */
|
|
860
|
+
private scrollList(pane: "sessions" | "tree", delta: number): void {
|
|
861
|
+
const visible = listVisibleRows(Math.max(8, this.o.getHeight()))[pane];
|
|
862
|
+
const total = pane === "sessions" ? this.sessions.length : this.visibleTree.length;
|
|
863
|
+
const next = clampFirst(this.listFirst(pane, visible) + delta, total, visible);
|
|
864
|
+
if (next === this.state.listScroll[pane]) return;
|
|
865
|
+
this.state.listScroll[pane] = next;
|
|
866
|
+
this.o.requestRender();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** Click: focus the pane under the pointer + select the row; double-click enters (sessions) / folds (tree). */
|
|
870
|
+
private handleClick(event: TuiMouseEvent): TuiMouseEventResult {
|
|
871
|
+
const target = this.hitTarget(event);
|
|
872
|
+
if (!target) return { handled: true };
|
|
873
|
+
const double = (event.clickCount ?? 1) >= 2;
|
|
874
|
+
// 点在面板任意位置都切焦点到对应面板(含边框 / 空白处)。
|
|
875
|
+
this.setFocus(target.pane);
|
|
876
|
+
if (target.pane === "sessions") {
|
|
877
|
+
if (target.row !== undefined) this.setSessionsCursor(target.row);
|
|
878
|
+
if (double) void this.resumeSession(); // 双击进入会话(等价于 Enter / resume)
|
|
879
|
+
} else if (target.pane === "tree") {
|
|
880
|
+
if (target.row !== undefined) this.setTreeCursor(target.row);
|
|
881
|
+
if (double) this.toggleTreeFold(); // 双击折叠 / 展开分支
|
|
882
|
+
}
|
|
883
|
+
return { handled: true };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Execute one logical action. Only the generic actions of this task are
|
|
888
|
+
* implemented; pane-specific ones show a short "not yet" status so the user
|
|
889
|
+
* can see the binding was recognised.
|
|
890
|
+
*/
|
|
891
|
+
dispatch(action: ActionId): void {
|
|
892
|
+
switch (action) {
|
|
893
|
+
case "quit":
|
|
894
|
+
this.close();
|
|
895
|
+
return;
|
|
896
|
+
case "focus-next":
|
|
897
|
+
this.cycleFocus(1);
|
|
898
|
+
return;
|
|
899
|
+
case "focus-prev":
|
|
900
|
+
this.cycleFocus(-1);
|
|
901
|
+
return;
|
|
902
|
+
case "focus-sessions":
|
|
903
|
+
this.setFocus("sessions");
|
|
904
|
+
return;
|
|
905
|
+
case "focus-tree":
|
|
906
|
+
this.setFocus("tree");
|
|
907
|
+
return;
|
|
908
|
+
case "focus-content":
|
|
909
|
+
this.setFocus("content");
|
|
910
|
+
return;
|
|
911
|
+
case "scope-current":
|
|
912
|
+
this.setScope("current-folder");
|
|
913
|
+
return;
|
|
914
|
+
case "scope-all":
|
|
915
|
+
this.setScope("all");
|
|
916
|
+
return;
|
|
917
|
+
case "help":
|
|
918
|
+
this.openHelp();
|
|
919
|
+
return;
|
|
920
|
+
case "search":
|
|
921
|
+
this.openSearch();
|
|
922
|
+
return;
|
|
923
|
+
case "search-next":
|
|
924
|
+
this.stepSearch(1);
|
|
925
|
+
return;
|
|
926
|
+
case "search-prev":
|
|
927
|
+
this.stepSearch(-1);
|
|
928
|
+
return;
|
|
929
|
+
case "move-down":
|
|
930
|
+
this.moveCursor(1);
|
|
931
|
+
return;
|
|
932
|
+
case "move-up":
|
|
933
|
+
this.moveCursor(-1);
|
|
934
|
+
return;
|
|
935
|
+
case "go-top":
|
|
936
|
+
this.moveCursorTo(0);
|
|
937
|
+
return;
|
|
938
|
+
case "go-bottom":
|
|
939
|
+
this.moveCursorTo(Number.MAX_SAFE_INTEGER);
|
|
940
|
+
return;
|
|
941
|
+
case "scroll-content-down":
|
|
942
|
+
this.scrollContent(this.contentPageStep());
|
|
943
|
+
return;
|
|
944
|
+
case "scroll-content-up":
|
|
945
|
+
this.scrollContent(-this.contentPageStep());
|
|
946
|
+
return;
|
|
947
|
+
case "tree-copy":
|
|
948
|
+
void this.copyTreeNode(this.currentTreeNode());
|
|
949
|
+
return;
|
|
950
|
+
case "tree-label":
|
|
951
|
+
this.openLabelInput(this.currentTreeNode());
|
|
952
|
+
return;
|
|
953
|
+
case "tree-open":
|
|
954
|
+
this.openTreeDialog();
|
|
955
|
+
return;
|
|
956
|
+
case "tree-fold":
|
|
957
|
+
this.toggleTreeFold();
|
|
958
|
+
return;
|
|
959
|
+
case "session-resume":
|
|
960
|
+
void this.resumeSession();
|
|
961
|
+
return;
|
|
962
|
+
case "tree-restore":
|
|
963
|
+
this.restoreTreeNode(this.currentTreeNode());
|
|
964
|
+
return;
|
|
965
|
+
case "session-delete":
|
|
966
|
+
this.confirmDeleteSession();
|
|
967
|
+
return;
|
|
968
|
+
case "session-rename":
|
|
969
|
+
this.openRenameInput();
|
|
970
|
+
return;
|
|
971
|
+
case "session-sort":
|
|
972
|
+
void this.cycleSort();
|
|
973
|
+
return;
|
|
974
|
+
case "session-info":
|
|
975
|
+
void this.openSessionInfo();
|
|
976
|
+
return;
|
|
977
|
+
case "session-new":
|
|
978
|
+
this.openNewSessionInput();
|
|
979
|
+
return;
|
|
980
|
+
case "session-fork":
|
|
981
|
+
void this.startFork();
|
|
982
|
+
return;
|
|
983
|
+
case "session-clone":
|
|
984
|
+
this.confirmCloneSession();
|
|
985
|
+
return;
|
|
986
|
+
case "session-compact":
|
|
987
|
+
this.openCompactInput();
|
|
988
|
+
return;
|
|
989
|
+
case "session-copy-last-reply":
|
|
990
|
+
void this.copyLastReply();
|
|
991
|
+
return;
|
|
992
|
+
case "session-export":
|
|
993
|
+
this.startExport();
|
|
994
|
+
return;
|
|
995
|
+
case "session-import":
|
|
996
|
+
this.openImportInput("");
|
|
997
|
+
return;
|
|
998
|
+
case "session-share":
|
|
999
|
+
this.confirmShareSession();
|
|
1000
|
+
return;
|
|
1001
|
+
case "session-toggle-select":
|
|
1002
|
+
this.toggleSelect();
|
|
1003
|
+
return;
|
|
1004
|
+
case "changelog":
|
|
1005
|
+
void this.openChangelog();
|
|
1006
|
+
return;
|
|
1007
|
+
default:
|
|
1008
|
+
this.setStatus(t("status.notImplemented", { action }));
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// -----------------------------------------------------------------------
|
|
1014
|
+
// Cursor movement
|
|
1015
|
+
// -----------------------------------------------------------------------
|
|
1016
|
+
|
|
1017
|
+
/** j/k: list panes move the cursor one row, the content pane scrolls one line. */
|
|
1018
|
+
private moveCursor(delta: number): void {
|
|
1019
|
+
if (this.state.focus === "content") {
|
|
1020
|
+
this.scrollContent(delta);
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
this.moveCursorTo(this.state.cursor[this.state.focus] + delta);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** gg/G and absolute moves; the index is clamped to the focused list. */
|
|
1027
|
+
private moveCursorTo(index: number): void {
|
|
1028
|
+
const pane = this.state.focus;
|
|
1029
|
+
if (pane === "content") this.setContentScroll(index);
|
|
1030
|
+
else if (pane === "sessions") this.setSessionsCursor(index);
|
|
1031
|
+
else this.setTreeCursor(index);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Move the sessions cursor (clamped) and reload TREE + CONTENT for the session it lands on. */
|
|
1035
|
+
private setSessionsCursor(index: number): void {
|
|
1036
|
+
// 光标一动就取消滚轮的独立滚动,重新按光标居中。
|
|
1037
|
+
this.state.listScroll.sessions = null;
|
|
1038
|
+
const next = clamp(index, 0, Math.max(0, this.sessions.length - 1));
|
|
1039
|
+
if (next === this.state.cursor.sessions) {
|
|
1040
|
+
this.o.requestRender();
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
this.state.cursor.sessions = next;
|
|
1044
|
+
this.o.requestRender();
|
|
1045
|
+
void this.scheduleSessionLoad();
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** Move the tree cursor (clamped, an index into the visible rows) and make the content pane follow. */
|
|
1049
|
+
private setTreeCursor(index: number): void {
|
|
1050
|
+
this.state.listScroll.tree = null;
|
|
1051
|
+
const next = clamp(index, 0, Math.max(0, this.visibleTree.length - 1));
|
|
1052
|
+
if (next === this.state.cursor.tree) {
|
|
1053
|
+
this.o.requestRender();
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
this.state.cursor.tree = next;
|
|
1057
|
+
this.o.requestRender();
|
|
1058
|
+
void this.syncContentToTree();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private scrollContent(delta: number): void {
|
|
1062
|
+
this.setContentScroll(this.state.cursor.content + delta);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private setContentScroll(line: number): void {
|
|
1066
|
+
const next = clamp(line, 0, this.contentMaxScroll());
|
|
1067
|
+
if (next === this.state.cursor.content) return;
|
|
1068
|
+
this.state.cursor.content = next;
|
|
1069
|
+
this.o.requestRender();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** J/K from the sessions pane scroll the content pane by half a viewport. */
|
|
1073
|
+
private contentPageStep(): number {
|
|
1074
|
+
return Math.max(1, Math.floor(this.contentView.visible / 2));
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
private cycleFocus(delta: 1 | -1): void {
|
|
1078
|
+
const i = PANE_IDS.indexOf(this.state.focus);
|
|
1079
|
+
this.setFocus(PANE_IDS[(i + delta + PANE_IDS.length) % PANE_IDS.length]!);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
private setFocus(pane: PaneId): void {
|
|
1083
|
+
this.state.focus = pane;
|
|
1084
|
+
this.o.requestRender();
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Switch the list scope; C / A are one-way so pressing the current one is a no-op. */
|
|
1088
|
+
private setScope(scope: ListScope): void {
|
|
1089
|
+
if (this.state.scope === scope) return;
|
|
1090
|
+
this.state.scope = scope;
|
|
1091
|
+
// 切换范围后光标回到顶部并重新拉取会话列表。
|
|
1092
|
+
this.state.cursor.sessions = 0;
|
|
1093
|
+
this.state.selectedSessionFiles.clear();
|
|
1094
|
+
void this.load();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// -----------------------------------------------------------------------
|
|
1098
|
+
// Search (/, n, N): lazygit-style, per pane, jump instead of filter
|
|
1099
|
+
// -----------------------------------------------------------------------
|
|
1100
|
+
|
|
1101
|
+
/** `/`: open the bar pre-filled with the pane's query (cursor at the end) and remember where the pane is. */
|
|
1102
|
+
private openSearch(): void {
|
|
1103
|
+
const pane = this.state.focus;
|
|
1104
|
+
this.searchOrigin = this.snapshotOrigin(pane);
|
|
1105
|
+
this.state.mode = "search";
|
|
1106
|
+
this.searchBar.reset(this.state.search[pane]?.query ?? "", true);
|
|
1107
|
+
this.searchBar.focused = this._focused;
|
|
1108
|
+
this.o.requestRender();
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** The focused pane's position as of `/`: the live search starts looking here and Esc comes back here. */
|
|
1112
|
+
private snapshotOrigin(pane: PaneId): SearchOrigin {
|
|
1113
|
+
if (pane === "tree") {
|
|
1114
|
+
const row = this.visibleTree[this.state.cursor.tree];
|
|
1115
|
+
// 树的匹配记的是整棵树的行号,所以原位置也换算成整棵树的行号;折叠状态一并记下。
|
|
1116
|
+
const index = row ? this.tree.findIndex((r) => r.entryId === row.entryId) : 0;
|
|
1117
|
+
return { pane, index: Math.max(0, index), ...(row ? { entryId: row.entryId } : {}), folded: new Set(this.state.treeFolded) };
|
|
1118
|
+
}
|
|
1119
|
+
return { pane, index: this.state.cursor[pane] };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** Every keystroke in the bar: search live (Enter / Esc have already left search mode when their keystroke lands here). */
|
|
1123
|
+
private onSearchInput(): void {
|
|
1124
|
+
if (this.state.mode === "search" && this.searchOrigin) this.applyLiveSearch(this.searchBar.getValue().trim());
|
|
1125
|
+
this.o.requestRender();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Search `query` in the pane the bar was opened for and jump to the first
|
|
1130
|
+
* match at or after the origin (wrapping to the first one). Every change
|
|
1131
|
+
* starts over from the origin, like vim's incsearch: an empty query, or one
|
|
1132
|
+
* without matches, puts the pane back where it was.
|
|
1133
|
+
*/
|
|
1134
|
+
private applyLiveSearch(query: string): void {
|
|
1135
|
+
const origin = this.searchOrigin;
|
|
1136
|
+
if (!origin) return;
|
|
1137
|
+
const pane = origin.pane;
|
|
1138
|
+
if (this.state.search[pane]?.query === query) return;
|
|
1139
|
+
if (!query) {
|
|
1140
|
+
delete this.state.search[pane];
|
|
1141
|
+
this.restoreOrigin(origin);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const matches = this.findMatches(pane, query);
|
|
1145
|
+
const search: PaneSearch = { query, matches, current: -1 };
|
|
1146
|
+
this.state.search[pane] = search;
|
|
1147
|
+
// 折叠先回到搜索前的样子,再为新的目标展开(上一次按键跳到的匹配可能展开了别的段)。
|
|
1148
|
+
if (pane === "tree" && origin.folded) this.state.treeFolded = new Set(origin.folded);
|
|
1149
|
+
const from = matches.findIndex((m) => m >= origin.index);
|
|
1150
|
+
const first = from >= 0 ? from : matches.length ? 0 : -1;
|
|
1151
|
+
if (first < 0) {
|
|
1152
|
+
this.restoreOrigin(origin);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
search.current = first;
|
|
1156
|
+
this.jumpToMatch(pane, matches[first]!);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Enter in the bar: keep the query (the live search already jumped) and hand the keys back to the pane. */
|
|
1160
|
+
private submitSearch(query: string): void {
|
|
1161
|
+
const q = query.trim();
|
|
1162
|
+
// 一般情况下实时搜索已经跑过;直接回车(比如打开后没改预填的关键字)时补一次。
|
|
1163
|
+
if (this.searchOrigin && this.state.search[this.searchOrigin.pane]?.query !== q) this.applyLiveSearch(q);
|
|
1164
|
+
this.state.mode = "normal";
|
|
1165
|
+
this.searchBar.focused = false;
|
|
1166
|
+
this.searchOrigin = undefined;
|
|
1167
|
+
this.o.requestRender();
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Esc in the bar: drop the query and put the pane back where `/` found it. */
|
|
1171
|
+
private cancelSearch(): void {
|
|
1172
|
+
const origin = this.searchOrigin;
|
|
1173
|
+
this.state.mode = "normal";
|
|
1174
|
+
this.searchBar.focused = false;
|
|
1175
|
+
this.searchOrigin = undefined;
|
|
1176
|
+
if (origin) {
|
|
1177
|
+
delete this.state.search[origin.pane];
|
|
1178
|
+
this.restoreOrigin(origin);
|
|
1179
|
+
}
|
|
1180
|
+
this.o.requestRender();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Esc in normal mode: end the pane's search, the cursor stays where it is. */
|
|
1184
|
+
private clearSearch(pane: PaneId): void {
|
|
1185
|
+
delete this.state.search[pane];
|
|
1186
|
+
this.o.requestRender();
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Put the pane back to where it was when `/` was pressed (folds included for the tree). */
|
|
1190
|
+
private restoreOrigin(origin: SearchOrigin): void {
|
|
1191
|
+
switch (origin.pane) {
|
|
1192
|
+
case "sessions":
|
|
1193
|
+
this.setSessionsCursor(origin.index);
|
|
1194
|
+
return;
|
|
1195
|
+
case "tree": {
|
|
1196
|
+
if (origin.folded) this.state.treeFolded = new Set(origin.folded);
|
|
1197
|
+
this.refreshTreeView();
|
|
1198
|
+
const idx = origin.entryId ? this.visibleTree.findIndex((r) => r.entryId === origin.entryId) : -1;
|
|
1199
|
+
this.placeTreeCursor(idx >= 0 ? idx : this.state.cursor.tree);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
case "content":
|
|
1203
|
+
this.setContentScroll(origin.index);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Put the tree cursor on visible row `index` and sync the content pane even if the index did not change (the rows under it may have). */
|
|
1209
|
+
private placeTreeCursor(index: number): void {
|
|
1210
|
+
this.state.cursor.tree = clamp(index, 0, Math.max(0, this.visibleTree.length - 1));
|
|
1211
|
+
this.o.requestRender();
|
|
1212
|
+
void this.syncContentToTree();
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** Matches of `query` in `pane`: row indices of the sessions list / the whole tree, or body lines of the content layout. */
|
|
1216
|
+
private findMatches(pane: PaneId, query: string): number[] {
|
|
1217
|
+
if (pane === "content") return this.contentMatches(query);
|
|
1218
|
+
const parsed = parseSearchQuery(query);
|
|
1219
|
+
if (pane === "sessions") return indicesWhere(this.sessions, (r) => matchSessionRow(r, parsed));
|
|
1220
|
+
return indicesWhere(this.tree, (r) => matchTreeRow(r, parsed));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Body lines of the current content layout matching `query` (free text only:
|
|
1225
|
+
* every token on the line, qualifiers mean nothing here). Cached per layout,
|
|
1226
|
+
* so a resize (which re-wraps the lines) recomputes them.
|
|
1227
|
+
*/
|
|
1228
|
+
private contentMatches(query: string): number[] {
|
|
1229
|
+
const layout = this.contentLayout();
|
|
1230
|
+
const c = this.contentSearchCache;
|
|
1231
|
+
if (c && c.layout === layout && c.query === query) return c.matches;
|
|
1232
|
+
const tokens = searchTokens(parseSearchQuery(query));
|
|
1233
|
+
const matches = tokens.length
|
|
1234
|
+
? indicesWhere(layout.lines, (line, i) => layout.searchable[i] === true && matchesTokens(stripTerminalSequences(line), tokens))
|
|
1235
|
+
: [];
|
|
1236
|
+
this.contentSearchCache = { layout, query, matches };
|
|
1237
|
+
return matches;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/** Recompute a list pane's matches after its rows changed; the query stays. */
|
|
1241
|
+
private refreshSearch(pane: "sessions" | "tree"): void {
|
|
1242
|
+
const search = this.state.search[pane];
|
|
1243
|
+
if (!search) return;
|
|
1244
|
+
search.matches = this.findMatches(pane, search.query);
|
|
1245
|
+
search.current = search.matches.length ? clamp(search.current, 0, search.matches.length - 1) : -1;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** Up-to-date matches of a pane's search (the content pane's follow the layout). */
|
|
1249
|
+
private matchesOf(pane: PaneId, search: PaneSearch): number[] {
|
|
1250
|
+
if (pane === "content") {
|
|
1251
|
+
search.matches = this.contentMatches(search.query);
|
|
1252
|
+
search.current = search.matches.length ? clamp(search.current, 0, search.matches.length - 1) : -1;
|
|
1253
|
+
}
|
|
1254
|
+
return search.matches;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** Row index in the whole tree of the pane's cursor row, -1 with no rows. */
|
|
1258
|
+
private treeCursorIndex(): number {
|
|
1259
|
+
const row = this.visibleTree[this.state.cursor.tree];
|
|
1260
|
+
return row ? this.tree.findIndex((r) => r.entryId === row.entryId) : -1;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* n / N: the next / previous match of the focused pane's search, wrapping
|
|
1265
|
+
* around. The list panes count from the cursor (vim's n / N), the content
|
|
1266
|
+
* pane from the match it last jumped to (its scroll position cannot always
|
|
1267
|
+
* reach the match, so it is no cursor).
|
|
1268
|
+
*/
|
|
1269
|
+
private stepSearch(delta: 1 | -1): void {
|
|
1270
|
+
const pane = this.state.focus;
|
|
1271
|
+
const search = this.state.search[pane];
|
|
1272
|
+
if (!search) {
|
|
1273
|
+
this.setStatus(t("status.noActiveSearch"));
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const matches = this.matchesOf(pane, search);
|
|
1277
|
+
if (matches.length === 0) {
|
|
1278
|
+
this.setStatus(t("status.noMatches"));
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
let next: number;
|
|
1282
|
+
if (pane === "content") {
|
|
1283
|
+
next = search.current < 0 ? (delta > 0 ? 0 : matches.length - 1) : (search.current + delta + matches.length) % matches.length;
|
|
1284
|
+
} else {
|
|
1285
|
+
const pos = pane === "sessions" ? this.state.cursor.sessions : this.treeCursorIndex();
|
|
1286
|
+
const i = delta > 0 ? matches.findIndex((m) => m > pos) : findLastIndex(matches, (m) => m < pos);
|
|
1287
|
+
next = i >= 0 ? i : delta > 0 ? 0 : matches.length - 1;
|
|
1288
|
+
}
|
|
1289
|
+
search.current = next;
|
|
1290
|
+
this.jumpToMatch(pane, matches[next]!);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Move the pane to match `index`: the sessions cursor, a tree row (unfolding what hides it), or the content line scrolled to the top. */
|
|
1294
|
+
private jumpToMatch(pane: PaneId, index: number): void {
|
|
1295
|
+
switch (pane) {
|
|
1296
|
+
case "sessions":
|
|
1297
|
+
this.setSessionsCursor(index);
|
|
1298
|
+
return;
|
|
1299
|
+
case "tree": {
|
|
1300
|
+
const row = this.tree[index];
|
|
1301
|
+
if (!row) return;
|
|
1302
|
+
// 目标藏在折叠段里:展开它的祖先,右侧内容跟着高亮。
|
|
1303
|
+
for (const id of foldedAncestors(this.tree, row.entryId, this.state.treeFolded)) this.state.treeFolded.delete(id);
|
|
1304
|
+
this.refreshTreeView();
|
|
1305
|
+
this.placeTreeCursor(this.visibleTree.findIndex((r) => r.entryId === row.entryId));
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
case "content":
|
|
1309
|
+
this.setContentScroll(index);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* What a pane paints for its search: the matches as indices into what it
|
|
1316
|
+
* renders (the tree pane lists the folded tree, so its matches are mapped
|
|
1317
|
+
* onto the visible rows), the current match and the header counts. For the
|
|
1318
|
+
* list panes the current match is the one the cursor is on, if any.
|
|
1319
|
+
*/
|
|
1320
|
+
private searchView(pane: PaneId): SearchView | undefined {
|
|
1321
|
+
const search = this.state.search[pane];
|
|
1322
|
+
if (!search) return undefined;
|
|
1323
|
+
const terms = highlightTerms(parseSearchQuery(search.query));
|
|
1324
|
+
const matches = this.matchesOf(pane, search);
|
|
1325
|
+
const total = matches.length;
|
|
1326
|
+
if (pane === "sessions") {
|
|
1327
|
+
const pos = matches.indexOf(this.state.cursor.sessions);
|
|
1328
|
+
return { terms, matches: new Set(matches), current: pos >= 0 ? this.state.cursor.sessions : undefined, position: pos + 1, total };
|
|
1329
|
+
}
|
|
1330
|
+
if (pane === "tree") {
|
|
1331
|
+
const matched = new Set(matches.map((i) => this.tree[i]?.entryId));
|
|
1332
|
+
const visible = new Set<number>();
|
|
1333
|
+
this.visibleTree.forEach((r, i) => {
|
|
1334
|
+
if (matched.has(r.entryId)) visible.add(i);
|
|
1335
|
+
});
|
|
1336
|
+
const pos = matches.indexOf(this.treeCursorIndex());
|
|
1337
|
+
return { terms, matches: visible, current: pos >= 0 ? this.state.cursor.tree : undefined, position: pos + 1, total };
|
|
1338
|
+
}
|
|
1339
|
+
const current = search.current >= 0 ? matches[search.current] : undefined;
|
|
1340
|
+
return { terms, matches: new Set(matches), current, position: search.current + 1, total };
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Footer hints while a search is active: the keys of `search-next` / `search-prev` (global) and Esc. */
|
|
1344
|
+
private searchHints(): KeyHint[] {
|
|
1345
|
+
const out: KeyHint[] = [];
|
|
1346
|
+
const next = labelsFor(this.keymap, "global", "search-next")[0];
|
|
1347
|
+
const prev = labelsFor(this.keymap, "global", "search-prev")[0];
|
|
1348
|
+
if (next) out.push([next, "next"]);
|
|
1349
|
+
if (prev) out.push([prev, "prev"]);
|
|
1350
|
+
out.push(["Esc", "clear"]);
|
|
1351
|
+
return out;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// -----------------------------------------------------------------------
|
|
1355
|
+
// Tree node actions: copy / label
|
|
1356
|
+
// -----------------------------------------------------------------------
|
|
1357
|
+
|
|
1358
|
+
/** Tree row under the pane's cursor plus the file it belongs to, or undefined with a footer hint. */
|
|
1359
|
+
private currentTreeNode(): TreeTarget | undefined {
|
|
1360
|
+
const row = this.visibleTree[this.state.cursor.tree];
|
|
1361
|
+
const file = this.loadedSessionFile;
|
|
1362
|
+
if (!row || !file) {
|
|
1363
|
+
this.setStatus(t("status.noTreeNode"));
|
|
1364
|
+
return undefined;
|
|
1365
|
+
}
|
|
1366
|
+
return { file, row };
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** y: copy the node's full text (like /tree ctrl+x). */
|
|
1370
|
+
private async copyTreeNode(target: TreeTarget | undefined): Promise<void> {
|
|
1371
|
+
if (!target) return;
|
|
1372
|
+
if (!this.o.actions) {
|
|
1373
|
+
this.setStatus(t("status.copyUnavailable"));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
try {
|
|
1377
|
+
const copied = await this.o.actions.copyNodeText(target.file, target.row.entryId);
|
|
1378
|
+
if (this.disposed) return;
|
|
1379
|
+
this.setStatus(copied ? t("status.copiedNode") : t("status.noTextToCopy"));
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
this.setStatus(t("status.copyFailed", { error: (err as Error).message }));
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** T: open the label dialog pre-filled with the node's current label. */
|
|
1386
|
+
private openLabelInput(target: TreeTarget | undefined): void {
|
|
1387
|
+
if (!target) return;
|
|
1388
|
+
if (!this.o.actions) {
|
|
1389
|
+
this.setStatus(t("status.labelUnavailable"));
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
this.labelTarget = { file: target.file, entryId: target.row.entryId };
|
|
1393
|
+
this.state.mode = "label";
|
|
1394
|
+
// 弹窗标题右侧显示是给哪条消息打标签。
|
|
1395
|
+
this.inputDialog.open({
|
|
1396
|
+
title: labelDialogTitle(),
|
|
1397
|
+
value: target.row.label ?? "",
|
|
1398
|
+
subject: `${target.row.role}: ${target.row.text}`,
|
|
1399
|
+
hints: labelDialogHints(),
|
|
1400
|
+
onSubmit: (v) => void this.submitLabel(v),
|
|
1401
|
+
onCancel: () => this.cancelLabel(),
|
|
1402
|
+
});
|
|
1403
|
+
this.inputDialog.focused = this._focused;
|
|
1404
|
+
this.o.requestRender();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** Enter in the label prompt: persist, then reload the tree so the row shows the new label. */
|
|
1408
|
+
private async submitLabel(value: string): Promise<void> {
|
|
1409
|
+
const target = this.labelTarget;
|
|
1410
|
+
this.closeLabelInput();
|
|
1411
|
+
if (!target || !this.o.actions) return;
|
|
1412
|
+
const label = value.trim() || undefined;
|
|
1413
|
+
try {
|
|
1414
|
+
await this.o.actions.setNodeLabel(target.file, target.entryId, label);
|
|
1415
|
+
if (this.disposed) return;
|
|
1416
|
+
this.setStatus(label ? t("status.labelSet", { label }) : t("status.labelRemoved"));
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
this.setStatus(t("status.labelFailed", { error: (err as Error).message }));
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
await this.reloadTree(target.file, target.entryId);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
private cancelLabel(): void {
|
|
1425
|
+
this.closeLabelInput();
|
|
1426
|
+
this.o.requestRender();
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
private closeLabelInput(): void {
|
|
1430
|
+
this.state.mode = this.baseMode();
|
|
1431
|
+
this.inputDialog.close();
|
|
1432
|
+
this.inputDialog.focused = false;
|
|
1433
|
+
this.labelTarget = undefined;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* Re-read the tree of `file` (same filter) and keep the cursor on `entryId`.
|
|
1438
|
+
* 在 labeled 过滤下清掉 label 会让这一行消失,此时光标夹回范围内并同步右侧高亮。
|
|
1439
|
+
* 树对话框开着的话它列出的行也跟着刷新(标签是在对话框里打的)。
|
|
1440
|
+
*/
|
|
1441
|
+
private async reloadTree(file: string, entryId: string): Promise<void> {
|
|
1442
|
+
try {
|
|
1443
|
+
const tree = await this.o.data.loadTree(file, this.state.treeFilter);
|
|
1444
|
+
if (this.disposed || this.loadedSessionFile !== file) return;
|
|
1445
|
+
// 同一个会话:保留折叠状态(不再是段头的 id 会被 applyTreeFold 忽略)。
|
|
1446
|
+
this.setTree(tree, this.state.treeFolded);
|
|
1447
|
+
const idx = this.visibleTree.findIndex((r) => r.entryId === entryId);
|
|
1448
|
+
this.state.cursor.tree = idx >= 0 ? idx : clamp(this.state.cursor.tree, 0, Math.max(0, this.visibleTree.length - 1));
|
|
1449
|
+
if (this.treeDialog.isOpen) this.refreshTreeDialog(entryId);
|
|
1450
|
+
await this.syncContentToTree();
|
|
1451
|
+
} catch (err) {
|
|
1452
|
+
this.setStatus(t("status.reloadTreeFailed", { error: (err as Error).message }));
|
|
1453
|
+
}
|
|
1454
|
+
this.o.requestRender();
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// -----------------------------------------------------------------------
|
|
1458
|
+
// Tree folding (z)
|
|
1459
|
+
// -----------------------------------------------------------------------
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* z: fold / unfold the branch segment under the cursor. On a segment head the
|
|
1463
|
+
* fold toggles; anywhere inside a segment it folds that segment and moves the
|
|
1464
|
+
* cursor onto its head (vim's zc). The trunk of a single-root tree has no
|
|
1465
|
+
* segment to fold.
|
|
1466
|
+
*/
|
|
1467
|
+
private toggleTreeFold(): void {
|
|
1468
|
+
const row = this.visibleTree[this.state.cursor.tree];
|
|
1469
|
+
if (!row) {
|
|
1470
|
+
this.setStatus(t("status.noTreeNode"));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const target = this.toggleFold(this.tree, row.entryId);
|
|
1474
|
+
if (!target) return;
|
|
1475
|
+
this.refreshTreeView();
|
|
1476
|
+
this.state.cursor.tree = Math.max(0, this.visibleTree.findIndex((r) => r.entryId === target));
|
|
1477
|
+
this.o.requestRender();
|
|
1478
|
+
// 光标从段内跳到了段头:右侧高亮跟着变。
|
|
1479
|
+
if (target !== row.entryId) void this.syncContentToTree();
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Toggle the fold of the segment `entryId` is in, looked up in `base` (the
|
|
1484
|
+
* pane's tree, or the dialog's search-narrowed rows). Returns the segment
|
|
1485
|
+
* head the cursor should land on, or undefined (with a footer hint) on the
|
|
1486
|
+
* trunk, where nothing folds.
|
|
1487
|
+
*/
|
|
1488
|
+
private toggleFold(base: TreeRow[], entryId: string): string | undefined {
|
|
1489
|
+
const target = foldTarget(base, entryId);
|
|
1490
|
+
if (!target) {
|
|
1491
|
+
this.setStatus(t("status.nothingToFold"));
|
|
1492
|
+
return undefined;
|
|
1493
|
+
}
|
|
1494
|
+
const folded = this.state.treeFolded;
|
|
1495
|
+
// 光标在段头上:切换;在段内其他行:折叠所在段(此时这一段一定是展开的)。
|
|
1496
|
+
if (target === entryId && folded.has(target)) folded.delete(target);
|
|
1497
|
+
else folded.add(target);
|
|
1498
|
+
return target;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// -----------------------------------------------------------------------
|
|
1502
|
+
// Tree dialog (a): the full tree in a big box
|
|
1503
|
+
// -----------------------------------------------------------------------
|
|
1504
|
+
|
|
1505
|
+
/** a: show the whole tree of the loaded session with the cursor on the pane's node (same fold state as the pane). */
|
|
1506
|
+
private openTreeDialog(): void {
|
|
1507
|
+
if (!this.loadedSessionFile) {
|
|
1508
|
+
this.setStatus(t("status.noSessionLoaded"));
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
this.state.mode = "tree";
|
|
1512
|
+
this.foldedBeforeSearch = undefined;
|
|
1513
|
+
// 面板里的旧提示(比如"按 a 打开对话框")到这里已经没用了,别留在对话框下面。
|
|
1514
|
+
this.status = undefined;
|
|
1515
|
+
const searchKey = labelsForFocus(this.keymap, TREE_DIALOG_SCOPE, "search")[0];
|
|
1516
|
+
this.treeDialog.open({
|
|
1517
|
+
// 刚打开时没有搜索,列出的行和小面板一样。
|
|
1518
|
+
rows: this.visibleTree,
|
|
1519
|
+
folded: this.state.treeFolded,
|
|
1520
|
+
initialIndex: this.state.cursor.tree,
|
|
1521
|
+
filter: this.state.treeFilter,
|
|
1522
|
+
hints: this.treeDialogHints(),
|
|
1523
|
+
...(searchKey ? { searchKey } : {}),
|
|
1524
|
+
onQueryChange: (q) => this.onDialogQueryChange(q),
|
|
1525
|
+
});
|
|
1526
|
+
this.treeDialog.focused = this._focused;
|
|
1527
|
+
this.o.requestRender();
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Esc / q on the list: back to the pane with its cursor on the dialog's row —
|
|
1532
|
+
* unfolding whatever hides it, so a node found by searching stays selected
|
|
1533
|
+
* and the content pane shows it. The search ends with the dialog: the folds
|
|
1534
|
+
* from before it come back (then the chosen row is revealed).
|
|
1535
|
+
*/
|
|
1536
|
+
private closeTreeDialog(): void {
|
|
1537
|
+
const row = this.treeDialog.selectedRow;
|
|
1538
|
+
this.state.mode = "normal";
|
|
1539
|
+
this.treeDialog.close();
|
|
1540
|
+
this.treeDialog.focused = false;
|
|
1541
|
+
// 搜索期间折叠是清空的:对话框一关搜索也就结束了,恢复搜索前的折叠状态。
|
|
1542
|
+
if (this.foldedBeforeSearch) {
|
|
1543
|
+
this.state.treeFolded = this.foldedBeforeSearch;
|
|
1544
|
+
this.foldedBeforeSearch = undefined;
|
|
1545
|
+
}
|
|
1546
|
+
if (row) {
|
|
1547
|
+
for (const id of foldedAncestors(this.tree, row.entryId, this.state.treeFolded)) this.state.treeFolded.delete(id);
|
|
1548
|
+
}
|
|
1549
|
+
this.refreshTreeView();
|
|
1550
|
+
const idx = row ? this.visibleTree.findIndex((r) => r.entryId === row.entryId) : -1;
|
|
1551
|
+
this.state.cursor.tree = idx >= 0 ? idx : clamp(this.state.cursor.tree, 0, Math.max(0, this.visibleTree.length - 1));
|
|
1552
|
+
this.o.requestRender();
|
|
1553
|
+
void this.syncContentToTree();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/** Bottom row / footer hints of the dialog, from the resolved keymap (`/ search`, `d/t/u/l/a filter`…). */
|
|
1557
|
+
private treeDialogHints(): KeyHint[] {
|
|
1558
|
+
const out: KeyHint[] = [];
|
|
1559
|
+
for (const group of TREE_DIALOG_FOOTER) {
|
|
1560
|
+
const keys = group.flatMap((a) => labelsForFocus(this.keymap, TREE_DIALOG_SCOPE, a).slice(0, 1));
|
|
1561
|
+
// 组里只要还有一个动作绑了键就显示,文字取组里第一个动作的。
|
|
1562
|
+
if (keys.length === 0) continue;
|
|
1563
|
+
out.push([compactKeys(keys), treeDialogHintText(group[0]!)]);
|
|
1564
|
+
}
|
|
1565
|
+
return out;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Keys while the dialog is open: the search row takes them all while it is
|
|
1570
|
+
* focused (Esc there only hands them back to the list); otherwise Esc closes
|
|
1571
|
+
* and everything else goes through the keymap with the `tree-dialog` scope
|
|
1572
|
+
* in front (then `tree`, then `global`).
|
|
1573
|
+
*/
|
|
1574
|
+
private handleTreeDialogInput(data: string): void {
|
|
1575
|
+
if (this.treeDialog.searchFocused) {
|
|
1576
|
+
this.treeDialog.handleSearchInput(data);
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
if (matchesKeyId(data, "escape")) {
|
|
1580
|
+
if (this.pending.length) this.clearPending();
|
|
1581
|
+
else this.closeTreeDialog();
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const pressed = [...this.pending, data];
|
|
1585
|
+
const result = resolveKeys(this.bindings, TREE_DIALOG_SCOPE, pressed);
|
|
1586
|
+
if (result.kind === "pending") {
|
|
1587
|
+
this.pending = pressed;
|
|
1588
|
+
this.armPendingTimer();
|
|
1589
|
+
this.o.requestRender();
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
this.clearPending();
|
|
1593
|
+
if (result.kind !== "action") return;
|
|
1594
|
+
// 外层 scope 里在对话框中关掉的动作(切面板、退出面板、n/N…)直接吞掉。
|
|
1595
|
+
if (result.scope !== TREE_DIALOG_SCOPE && isDisabledIn(TREE_DIALOG_SCOPE, result.action)) return;
|
|
1596
|
+
this.dispatchInTreeDialog(result.action);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/** The dialog's actions: its own keys plus the tree pane's, acting on the dialog's cursor. */
|
|
1600
|
+
private dispatchInTreeDialog(action: ActionId): void {
|
|
1601
|
+
switch (action) {
|
|
1602
|
+
case "move-down":
|
|
1603
|
+
this.treeDialog.move(1);
|
|
1604
|
+
return;
|
|
1605
|
+
case "move-up":
|
|
1606
|
+
this.treeDialog.move(-1);
|
|
1607
|
+
return;
|
|
1608
|
+
case "go-top":
|
|
1609
|
+
this.treeDialog.moveTo(0);
|
|
1610
|
+
return;
|
|
1611
|
+
case "go-bottom":
|
|
1612
|
+
this.treeDialog.moveTo(Number.MAX_SAFE_INTEGER);
|
|
1613
|
+
return;
|
|
1614
|
+
case "search":
|
|
1615
|
+
this.treeDialog.focusSearch();
|
|
1616
|
+
return;
|
|
1617
|
+
case "tree-copy":
|
|
1618
|
+
void this.copyTreeNode(this.dialogTreeNode());
|
|
1619
|
+
return;
|
|
1620
|
+
case "tree-label":
|
|
1621
|
+
this.openLabelInput(this.dialogTreeNode());
|
|
1622
|
+
return;
|
|
1623
|
+
case "tree-restore":
|
|
1624
|
+
this.restoreTreeNode(this.dialogTreeNode());
|
|
1625
|
+
return;
|
|
1626
|
+
case "tree-fold":
|
|
1627
|
+
this.dialogToggleFold();
|
|
1628
|
+
return;
|
|
1629
|
+
case "tree-filter-default":
|
|
1630
|
+
void this.setTreeFilter("default");
|
|
1631
|
+
return;
|
|
1632
|
+
case "tree-filter-no-tools":
|
|
1633
|
+
void this.toggleTreeFilter("no-tools");
|
|
1634
|
+
return;
|
|
1635
|
+
case "tree-filter-user":
|
|
1636
|
+
void this.toggleTreeFilter("user-only");
|
|
1637
|
+
return;
|
|
1638
|
+
case "tree-filter-labeled":
|
|
1639
|
+
void this.toggleTreeFilter("labeled");
|
|
1640
|
+
return;
|
|
1641
|
+
case "tree-filter-all":
|
|
1642
|
+
void this.toggleTreeFilter("all");
|
|
1643
|
+
return;
|
|
1644
|
+
case "tree-dialog-close":
|
|
1645
|
+
this.closeTreeDialog();
|
|
1646
|
+
return;
|
|
1647
|
+
default:
|
|
1648
|
+
// 其余动作(比如 tree-open)在对话框里没有意义。
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/** The dialog's cursor row plus the loaded session, or undefined with a footer hint. */
|
|
1654
|
+
private dialogTreeNode(): TreeTarget | undefined {
|
|
1655
|
+
const row = this.treeDialog.selectedRow;
|
|
1656
|
+
const file = this.loadedSessionFile;
|
|
1657
|
+
if (!row || !file) {
|
|
1658
|
+
this.setStatus(t("status.noTreeNode"));
|
|
1659
|
+
return undefined;
|
|
1660
|
+
}
|
|
1661
|
+
return { file, row };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Rows of the dialog before folding: the tree, narrowed to the search matches (re-parented) while a query is active. */
|
|
1665
|
+
private dialogBaseRows(): TreeRow[] {
|
|
1666
|
+
const raw = this.treeDialog.searchQuery;
|
|
1667
|
+
if (!raw) return this.tree;
|
|
1668
|
+
const query = parseSearchQuery(raw);
|
|
1669
|
+
return filterTreeRows(this.tree, (r) => matchTreeRow(r, query));
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** Recompute what the dialog lists (search → fold) and keep its cursor on `keepEntryId` or the nearest listed ancestor. */
|
|
1673
|
+
private refreshTreeDialog(keepEntryId: string | undefined): void {
|
|
1674
|
+
const rows = applyTreeFold(this.dialogBaseRows(), this.state.treeFolded);
|
|
1675
|
+
this.treeDialog.setRows(rows, this.state.treeFolded, nearestListedIndex(rows, this.tree, keepEntryId));
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Live search: every keystroke in the search row lands here. Like pi's
|
|
1680
|
+
* /tree, a search clears the folds so every match is visible; the fold state
|
|
1681
|
+
* from before the search comes back once the query is empty again (or the
|
|
1682
|
+
* dialog closes).
|
|
1683
|
+
*/
|
|
1684
|
+
private onDialogQueryChange(query: string): void {
|
|
1685
|
+
const keep = this.treeDialog.selectedRow?.entryId;
|
|
1686
|
+
if (query) {
|
|
1687
|
+
// 第一次开始搜索时记住原来的折叠状态;之后每次改动查询都重新清空(pi 的做法)。
|
|
1688
|
+
this.foldedBeforeSearch ??= new Set(this.state.treeFolded);
|
|
1689
|
+
this.state.treeFolded.clear();
|
|
1690
|
+
} else if (this.foldedBeforeSearch) {
|
|
1691
|
+
this.state.treeFolded = this.foldedBeforeSearch;
|
|
1692
|
+
this.foldedBeforeSearch = undefined;
|
|
1693
|
+
}
|
|
1694
|
+
this.refreshTreeView();
|
|
1695
|
+
this.refreshTreeDialog(keep);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* z in the dialog: the pane's fold toggle on the shared fold state, with the
|
|
1700
|
+
* segment head looked up in the search-narrowed rows; the cursor lands on
|
|
1701
|
+
* that head.
|
|
1702
|
+
*/
|
|
1703
|
+
private dialogToggleFold(): void {
|
|
1704
|
+
const row = this.treeDialog.selectedRow;
|
|
1705
|
+
if (!row) {
|
|
1706
|
+
this.setStatus(t("status.noTreeNode"));
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const target = this.toggleFold(this.dialogBaseRows(), row.entryId);
|
|
1710
|
+
if (!target) return;
|
|
1711
|
+
this.refreshTreeView();
|
|
1712
|
+
this.refreshTreeDialog(target);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** t / u / l / a: switch to `filter`, or back to default when it is already active (pi's toggles). */
|
|
1716
|
+
private toggleTreeFilter(filter: TreeFilter): Promise<void> {
|
|
1717
|
+
return this.setTreeFilter(this.state.treeFilter === filter ? "default" : filter);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* d (and the toggles): reload the tree with `filter`. Folds are cleared like
|
|
1722
|
+
* pi does (a folded side branch would hide the labeled rows `l` asks for);
|
|
1723
|
+
* the cursor stays on its row or the nearest listed ancestor.
|
|
1724
|
+
*/
|
|
1725
|
+
private async setTreeFilter(filter: TreeFilter): Promise<void> {
|
|
1726
|
+
const file = this.loadedSessionFile;
|
|
1727
|
+
if (!file || filter === this.state.treeFilter) return;
|
|
1728
|
+
this.state.treeFilter = filter;
|
|
1729
|
+
this.treeDialog.setFilter(filter);
|
|
1730
|
+
const keep = this.treeDialog.selectedRow?.entryId;
|
|
1731
|
+
try {
|
|
1732
|
+
const tree = await this.o.data.loadTree(file, filter);
|
|
1733
|
+
if (this.disposed || this.loadedSessionFile !== file || this.state.treeFilter !== filter) return;
|
|
1734
|
+
// 换过滤后全部展开;搜索前记住的折叠状态是旧树的,一并作废。
|
|
1735
|
+
this.foldedBeforeSearch = undefined;
|
|
1736
|
+
this.setTree(tree, new Set());
|
|
1737
|
+
const idx = keep ? this.visibleTree.findIndex((r) => r.entryId === keep) : -1;
|
|
1738
|
+
this.state.cursor.tree = idx >= 0 ? idx : clamp(this.state.cursor.tree, 0, Math.max(0, this.visibleTree.length - 1));
|
|
1739
|
+
this.refreshTreeDialog(keep);
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
this.setStatus(t("status.reloadTreeFailed", { error: (err as Error).message }));
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// -----------------------------------------------------------------------
|
|
1746
|
+
// Session actions: delete (d) / rename (r) / sort (s) / info (i)
|
|
1747
|
+
// -----------------------------------------------------------------------
|
|
1748
|
+
|
|
1749
|
+
/** Session row under the cursor, or undefined with a footer hint. */
|
|
1750
|
+
private currentSessionRow(): SessionRow | undefined {
|
|
1751
|
+
const row = this.sessions[this.state.cursor.sessions];
|
|
1752
|
+
if (!row) this.setStatus(t("status.noSessionSelected"));
|
|
1753
|
+
return row;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Title of a session as the pane shows it: its name, else the first-message preview. */
|
|
1757
|
+
private sessionTitle(row: SessionRow): string {
|
|
1758
|
+
return row.name ?? row.preview ?? "(empty session)";
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* d: ask before deleting the session under the cursor. The session pi has
|
|
1763
|
+
* open is refused up front, like pi's /resume (no dialog, just the message).
|
|
1764
|
+
*/
|
|
1765
|
+
private confirmDeleteSession(): void {
|
|
1766
|
+
if (this.state.selectedSessionFiles.size > 0) {
|
|
1767
|
+
this.confirmDeleteSelected();
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
const row = this.currentSessionRow();
|
|
1771
|
+
if (!row) return;
|
|
1772
|
+
if (!this.o.actions?.deleteSession) {
|
|
1773
|
+
this.setStatus(t("status.deleteUnavailable"));
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
// 和 pi 内置 /resume 一样:当前打开的会话直接拒绝,不弹确认框。
|
|
1777
|
+
if (findSessionIndex([row], this.currentSessionFile) === 0) {
|
|
1778
|
+
this.setStatus(t("status.cannotDeleteActive"));
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
this.deleteTarget = row;
|
|
1782
|
+
this.state.mode = "confirm";
|
|
1783
|
+
this.selectDialog.open(
|
|
1784
|
+
confirmDialogSpec({
|
|
1785
|
+
title: deleteSessionTitle(),
|
|
1786
|
+
subject: this.sessionTitle(row),
|
|
1787
|
+
onConfirm: () => void this.deleteSession(),
|
|
1788
|
+
onCancel: () => this.closeConfirm(),
|
|
1789
|
+
}),
|
|
1790
|
+
);
|
|
1791
|
+
this.o.requestRender();
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* d with a multi-selection: one confirmation for all selected sessions. The
|
|
1796
|
+
* session pi has open is left out (like a single d refuses it); when that
|
|
1797
|
+
* leaves nothing the footer says so without asking.
|
|
1798
|
+
*
|
|
1799
|
+
* 批量删除:列表顺序排好,跳过 pi 当前打开的会话,确认框标题带数量。
|
|
1800
|
+
*/
|
|
1801
|
+
private confirmDeleteSelected(): void {
|
|
1802
|
+
if (!this.o.actions?.deleteSession) {
|
|
1803
|
+
this.setStatus(t("status.deleteUnavailable"));
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
const rows = this.sessions.filter((r) => this.state.selectedSessionFiles.has(r.file));
|
|
1807
|
+
const targets = rows.filter((r) => findSessionIndex([r], this.currentSessionFile) !== 0);
|
|
1808
|
+
if (targets.length === 0) {
|
|
1809
|
+
this.setStatus(t("status.cannotDeleteActive"));
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
const skipped = rows.length - targets.length;
|
|
1813
|
+
// 当前打开的会话被跳过:它不会被删,也不该继续留在选中里。
|
|
1814
|
+
for (const r of rows) if (!targets.includes(r)) this.state.selectedSessionFiles.delete(r.file);
|
|
1815
|
+
this.batchDeleteTargets = targets;
|
|
1816
|
+
this.state.mode = "confirm";
|
|
1817
|
+
this.selectDialog.open(
|
|
1818
|
+
confirmDialogSpec({
|
|
1819
|
+
title: deleteSessionsTitle(targets.length),
|
|
1820
|
+
subject: skipped
|
|
1821
|
+
? t("confirm.deleteSkipped", { subjects: targets.map((r) => this.sessionTitle(r)).join(", ") })
|
|
1822
|
+
: targets.map((r) => this.sessionTitle(r)).join(", "),
|
|
1823
|
+
onConfirm: () => void this.deleteSelected(),
|
|
1824
|
+
onCancel: () => this.closeConfirm(),
|
|
1825
|
+
}),
|
|
1826
|
+
);
|
|
1827
|
+
this.o.requestRender();
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Yes on the batch confirmation: delete one by one; the ones that fail stay
|
|
1832
|
+
* listed and selected, the first error goes to the footer.
|
|
1833
|
+
*/
|
|
1834
|
+
private async deleteSelected(): Promise<void> {
|
|
1835
|
+
const targets = this.batchDeleteTargets ?? [];
|
|
1836
|
+
this.closeConfirm();
|
|
1837
|
+
const remove = this.o.actions?.deleteSession;
|
|
1838
|
+
if (!remove || targets.length === 0) return;
|
|
1839
|
+
this.setStatus(t("status.deletingN", { count: targets.length }));
|
|
1840
|
+
let deleted = 0;
|
|
1841
|
+
let firstError: string | undefined;
|
|
1842
|
+
for (const row of targets) {
|
|
1843
|
+
try {
|
|
1844
|
+
await remove(row.file);
|
|
1845
|
+
deleted++;
|
|
1846
|
+
this.state.selectedSessionFiles.delete(row.file);
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
firstError ??= `${this.sessionTitle(row)}: ${(err as Error).message}`;
|
|
1849
|
+
}
|
|
1850
|
+
if (this.disposed) return;
|
|
1851
|
+
}
|
|
1852
|
+
const failed = targets.length - deleted;
|
|
1853
|
+
const summary = failed ? t("status.batchDeletedFailed", { deleted, failed, error: firstError }) : t("status.sessionsDeleted", { count: deleted });
|
|
1854
|
+
if (await this.listSessions(undefined)) this.setStatus(summary);
|
|
1855
|
+
await this.followSessionsCursor();
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/** space: toggle the session under the cursor in the multi-selection. */
|
|
1859
|
+
private toggleSelect(): void {
|
|
1860
|
+
const row = this.currentSessionRow();
|
|
1861
|
+
if (!row) return;
|
|
1862
|
+
const selected = this.state.selectedSessionFiles;
|
|
1863
|
+
if (selected.has(row.file)) selected.delete(row.file);
|
|
1864
|
+
else selected.add(row.file);
|
|
1865
|
+
this.o.requestRender();
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
private clearSelection(): void {
|
|
1869
|
+
this.state.selectedSessionFiles.clear();
|
|
1870
|
+
this.setStatus(t("status.selectionCleared"));
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* Actions that only make sense for one session (rename, fork, clone, export,
|
|
1875
|
+
* share) refuse while several sessions are selected. Returns true when refused.
|
|
1876
|
+
*/
|
|
1877
|
+
private refuseMultiSelect(label: string): boolean {
|
|
1878
|
+
if (this.state.selectedSessionFiles.size <= 1) return false;
|
|
1879
|
+
this.setStatus(t("status.multiSelectRefused", { action: t(`enter.${label}`) }));
|
|
1880
|
+
return true;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** @: show pi's changelog. Rendering the whole file is slow, so the box opens with a loading spinner first. */
|
|
1884
|
+
private async openChangelog(): Promise<void> {
|
|
1885
|
+
const load = this.o.data.loadChangelog;
|
|
1886
|
+
if (!load) {
|
|
1887
|
+
this.setStatus(t("status.changelogUnavailable"));
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
this.state.mode = "changelog";
|
|
1891
|
+
this.status = undefined;
|
|
1892
|
+
// 已经加载过:直接用缓存内容打开,渲染结果按宽度缓存,秒开,不再显示加载中。
|
|
1893
|
+
if (this.changelogMd !== undefined) {
|
|
1894
|
+
this.changelogDialog.setContent(this.changelogMd);
|
|
1895
|
+
this.o.requestRender();
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
// 首次打开:Markdown 渲染整份 changelog(几千行)是同步的、比较慢。先画出"加载中"的弹窗
|
|
1899
|
+
// (底部转方块 + 文案),让反馈在那次卡顿渲染之前先出现。
|
|
1900
|
+
this.changelogDialog.openLoading();
|
|
1901
|
+
this.startChangelogSpinner();
|
|
1902
|
+
this.o.requestRender();
|
|
1903
|
+
let markdown: string;
|
|
1904
|
+
try {
|
|
1905
|
+
markdown = await load();
|
|
1906
|
+
} catch (err) {
|
|
1907
|
+
this.stopChangelogSpinner();
|
|
1908
|
+
if (this.disposed) return;
|
|
1909
|
+
this.changelogDialog.close();
|
|
1910
|
+
this.state.mode = this.baseMode();
|
|
1911
|
+
this.setStatus(t("status.changelogFailed", { error: (err as Error).message }));
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
// 先让"加载中"那一帧画出来,再做同步的重渲染(否则两次 requestRender 可能被合并,加载提示看不见)。
|
|
1915
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1916
|
+
// 加载期间用户可能已经关掉弹窗或退出了面板。
|
|
1917
|
+
if (this.disposed || !this.changelogDialog.isLoading) {
|
|
1918
|
+
this.stopChangelogSpinner();
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
this.stopChangelogSpinner();
|
|
1922
|
+
this.changelogMd = markdown;
|
|
1923
|
+
this.changelogDialog.setContent(markdown);
|
|
1924
|
+
this.o.requestRender();
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
private closeChangelog(): void {
|
|
1928
|
+
this.stopChangelogSpinner();
|
|
1929
|
+
this.changelogDialog.close();
|
|
1930
|
+
this.state.mode = this.baseMode();
|
|
1931
|
+
this.o.requestRender();
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/** Rotate the changelog loading spinner one frame every SPINNER_INTERVAL_MS. */
|
|
1935
|
+
private startChangelogSpinner(): void {
|
|
1936
|
+
this.stopChangelogSpinner();
|
|
1937
|
+
const timer = setInterval(() => {
|
|
1938
|
+
this.changelogDialog.advanceSpinner();
|
|
1939
|
+
this.o.requestRender();
|
|
1940
|
+
}, SPINNER_INTERVAL_MS);
|
|
1941
|
+
// unref:加载很快时不让这个定时器拖住进程(尤其是测试)。
|
|
1942
|
+
timer.unref?.();
|
|
1943
|
+
this.changelogSpinner = timer;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
private stopChangelogSpinner(): void {
|
|
1947
|
+
if (this.changelogSpinner) clearInterval(this.changelogSpinner);
|
|
1948
|
+
this.changelogSpinner = undefined;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
private closeConfirm(): void {
|
|
1952
|
+
this.state.mode = this.baseMode();
|
|
1953
|
+
this.selectDialog.close();
|
|
1954
|
+
this.deleteTarget = undefined;
|
|
1955
|
+
this.batchDeleteTargets = undefined;
|
|
1956
|
+
this.o.requestRender();
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
/** Yes in the confirmation: remove the file, then re-list with the cursor clamped (TREE / CONTENT follow). */
|
|
1960
|
+
private async deleteSession(): Promise<void> {
|
|
1961
|
+
const row = this.deleteTarget;
|
|
1962
|
+
this.closeConfirm();
|
|
1963
|
+
const remove = this.o.actions?.deleteSession;
|
|
1964
|
+
if (!row || !remove) return;
|
|
1965
|
+
this.setStatus(t("status.deleting"));
|
|
1966
|
+
let method: DeleteMethod;
|
|
1967
|
+
try {
|
|
1968
|
+
method = await remove(row.file);
|
|
1969
|
+
if (this.disposed) return;
|
|
1970
|
+
} catch (err) {
|
|
1971
|
+
this.setStatus(t("status.deleteFailed", { error: (err as Error).message }));
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
this.state.selectedSessionFiles.delete(row.file);
|
|
1975
|
+
// 删掉的行没了,光标夹回范围内;光标下换了会话就重新加载右边。
|
|
1976
|
+
if (await this.listSessions(undefined)) this.setStatus(method === "trash" ? t("status.movedToTrash") : t("status.deleted"));
|
|
1977
|
+
await this.followSessionsCursor();
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/** r: open the Rename prompt pre-filled with the session's current name. */
|
|
1981
|
+
private openRenameInput(): void {
|
|
1982
|
+
if (this.refuseMultiSelect("rename")) return;
|
|
1983
|
+
const row = this.currentSessionRow();
|
|
1984
|
+
if (!row) return;
|
|
1985
|
+
if (!this.o.actions?.renameSession) {
|
|
1986
|
+
this.setStatus(t("status.renameUnavailable"));
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
this.renameTarget = row;
|
|
1990
|
+
this.state.mode = "rename";
|
|
1991
|
+
// 弹窗标题右侧显示是给哪个会话改名(首条消息预览,名字本身在输入框里)。
|
|
1992
|
+
this.inputDialog.open({
|
|
1993
|
+
title: renameDialogTitle(),
|
|
1994
|
+
value: row.name ?? "",
|
|
1995
|
+
subject: row.preview || row.id,
|
|
1996
|
+
hints: renameDialogHints(),
|
|
1997
|
+
onSubmit: (v) => void this.submitRename(v),
|
|
1998
|
+
onCancel: () => this.closeRenameInput(),
|
|
1999
|
+
});
|
|
2000
|
+
this.inputDialog.focused = this._focused;
|
|
2001
|
+
this.o.requestRender();
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/** Enter in the Rename prompt: persist, then re-list so the row shows the new name (cursor stays on it). */
|
|
2005
|
+
private async submitRename(value: string): Promise<void> {
|
|
2006
|
+
const row = this.renameTarget;
|
|
2007
|
+
this.closeRenameInput();
|
|
2008
|
+
const rename = this.o.actions?.renameSession;
|
|
2009
|
+
if (!row || !rename) return;
|
|
2010
|
+
const name = value.trim();
|
|
2011
|
+
try {
|
|
2012
|
+
await rename(row.file, name);
|
|
2013
|
+
if (this.disposed) return;
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
this.setStatus(t("status.renameFailed", { error: (err as Error).message }));
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
if (await this.listSessions(row.file)) this.setStatus(name ? t("status.renamed", { name }) : t("status.nameRemoved"));
|
|
2019
|
+
// 改名会在会话文件里追加一条 session_info:TREE 在 all 过滤下要能看到它,光标留在原节点。
|
|
2020
|
+
if (row.file === this.loadedSessionFile) {
|
|
2021
|
+
const entryId = this.visibleTree[this.state.cursor.tree]?.entryId;
|
|
2022
|
+
if (entryId) await this.reloadTree(row.file, entryId);
|
|
2023
|
+
} else {
|
|
2024
|
+
await this.followSessionsCursor();
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
private closeRenameInput(): void {
|
|
2029
|
+
this.state.mode = this.baseMode();
|
|
2030
|
+
this.inputDialog.close();
|
|
2031
|
+
this.inputDialog.focused = false;
|
|
2032
|
+
this.renameTarget = undefined;
|
|
2033
|
+
this.o.requestRender();
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/** n: prompt for an optional name, then start a fresh session (/new) and close the panel. */
|
|
2037
|
+
private openNewSessionInput(): void {
|
|
2038
|
+
if (!this.o.actions?.newSession) {
|
|
2039
|
+
this.setStatus(t("status.newUnavailable"));
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
this.state.mode = "new";
|
|
2043
|
+
this.inputDialog.open({
|
|
2044
|
+
title: newSessionDialogTitle(),
|
|
2045
|
+
value: "",
|
|
2046
|
+
hints: newSessionDialogHints(),
|
|
2047
|
+
onSubmit: (v) => void this.submitNewSession(v),
|
|
2048
|
+
onCancel: () => this.closeNewSessionInput(),
|
|
2049
|
+
});
|
|
2050
|
+
this.inputDialog.focused = this._focused;
|
|
2051
|
+
this.o.requestRender();
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/** Enter in the New session prompt: create it (naming it when non-empty), then close via `enter()`. */
|
|
2055
|
+
private async submitNewSession(value: string): Promise<void> {
|
|
2056
|
+
const create = this.o.actions?.newSession;
|
|
2057
|
+
this.closeNewSessionInput();
|
|
2058
|
+
if (!create) return;
|
|
2059
|
+
const name = value.trim();
|
|
2060
|
+
await this.enter("new", () => create(name));
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private closeNewSessionInput(): void {
|
|
2064
|
+
this.state.mode = this.baseMode();
|
|
2065
|
+
this.inputDialog.close();
|
|
2066
|
+
this.inputDialog.focused = false;
|
|
2067
|
+
this.o.requestRender();
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
/** c: prompt for optional focus instructions, then compact the cursor session (/compact) and open it. */
|
|
2071
|
+
private openCompactInput(): void {
|
|
2072
|
+
if (this.refuseMultiSelect("compact")) return;
|
|
2073
|
+
const row = this.currentSessionRow();
|
|
2074
|
+
if (!row) return;
|
|
2075
|
+
if (!this.o.actions?.compactSession) {
|
|
2076
|
+
this.setStatus(t("status.compactUnavailable"));
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
this.compactTarget = row;
|
|
2080
|
+
this.state.mode = "compact";
|
|
2081
|
+
// 标题右侧显示压缩的是哪个会话(首条消息预览);输入框留空 = 用 pi 的默认压缩指令。
|
|
2082
|
+
this.inputDialog.open({
|
|
2083
|
+
title: compactDialogTitle(),
|
|
2084
|
+
value: "",
|
|
2085
|
+
subject: row.preview || row.id,
|
|
2086
|
+
hints: compactDialogHints(),
|
|
2087
|
+
onSubmit: (v) => void this.submitCompact(v),
|
|
2088
|
+
onCancel: () => this.closeCompactInput(),
|
|
2089
|
+
});
|
|
2090
|
+
this.inputDialog.focused = this._focused;
|
|
2091
|
+
this.o.requestRender();
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/** Enter in the Compact prompt: compact the session (blank = pi's default instructions), then close via `enter()`. */
|
|
2095
|
+
private async submitCompact(value: string): Promise<void> {
|
|
2096
|
+
const row = this.compactTarget;
|
|
2097
|
+
const compact = this.o.actions?.compactSession;
|
|
2098
|
+
this.closeCompactInput();
|
|
2099
|
+
if (!row || !compact) return;
|
|
2100
|
+
const instructions = value.trim();
|
|
2101
|
+
await this.enter("compact", () => compact(row.file, instructions || undefined));
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
private closeCompactInput(): void {
|
|
2105
|
+
this.state.mode = this.baseMode();
|
|
2106
|
+
this.inputDialog.close();
|
|
2107
|
+
this.inputDialog.focused = false;
|
|
2108
|
+
this.compactTarget = undefined;
|
|
2109
|
+
this.o.requestRender();
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** o: pick the user message to fork before (pi's /fork selector), then confirm and fork. */
|
|
2113
|
+
private async startFork(): Promise<void> {
|
|
2114
|
+
if (this.refuseMultiSelect("fork")) return;
|
|
2115
|
+
const row = this.currentSessionRow();
|
|
2116
|
+
if (!row) return;
|
|
2117
|
+
if (!this.o.actions?.forkSession || !this.o.data.loadForkPoints) {
|
|
2118
|
+
this.setStatus(t("status.forkUnavailable"));
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
let points: ForkPoint[];
|
|
2122
|
+
try {
|
|
2123
|
+
points = await this.o.data.loadForkPoints(row.file);
|
|
2124
|
+
if (this.disposed) return;
|
|
2125
|
+
} catch (err) {
|
|
2126
|
+
this.setStatus(t("status.forkFailed", { error: (err as Error).message }));
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
if (points.length === 0) {
|
|
2130
|
+
this.setStatus(t("status.noForkMessages"));
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
this.forkTarget = { file: row.file, subject: this.sessionTitle(row), points };
|
|
2134
|
+
// 默认停在最后一条 user 消息(和 pi 内置 /fork 一致)。
|
|
2135
|
+
this.openForkSelector(points.length - 1);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/** The fork selector with the cursor on `index` (Esc / No on the confirmation comes back onto that message). */
|
|
2139
|
+
private openForkSelector(index: number): void {
|
|
2140
|
+
const target = this.forkTarget;
|
|
2141
|
+
if (!target) return;
|
|
2142
|
+
this.state.mode = "fork";
|
|
2143
|
+
this.selectDialog.open({
|
|
2144
|
+
title: forkDialogTitle(),
|
|
2145
|
+
items: target.points.map((p) => p.text),
|
|
2146
|
+
initialIndex: index,
|
|
2147
|
+
subject: target.subject,
|
|
2148
|
+
hints: forkDialogHints(),
|
|
2149
|
+
// 消息多了按窗口滚动,不撑破终端。
|
|
2150
|
+
maxRows: this.dialogMaxRows(),
|
|
2151
|
+
onSelect: (i) => this.confirmFork(i),
|
|
2152
|
+
onCancel: () => this.closeForkDialogs(),
|
|
2153
|
+
});
|
|
2154
|
+
this.o.requestRender();
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** A picked message → the Yes / No confirmation (CLAUDE.md rule 7) before the fork happens. */
|
|
2158
|
+
private confirmFork(index: number): void {
|
|
2159
|
+
const target = this.forkTarget;
|
|
2160
|
+
const point = target?.points[index];
|
|
2161
|
+
if (!target || !point) {
|
|
2162
|
+
this.closeForkDialogs();
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
this.state.mode = "fork";
|
|
2166
|
+
this.selectDialog.open(
|
|
2167
|
+
confirmDialogSpec({
|
|
2168
|
+
title: forkSessionTitle(),
|
|
2169
|
+
subject: point.text,
|
|
2170
|
+
onConfirm: () => void this.runFork(target.file, point.entryId),
|
|
2171
|
+
// Esc / No:退回选择器,光标停在刚选中的那条消息上。
|
|
2172
|
+
onCancel: () => this.openForkSelector(index),
|
|
2173
|
+
}),
|
|
2174
|
+
);
|
|
2175
|
+
this.o.requestRender();
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/** Confirmed: fork before the picked message (pi puts its text back into the fork's editor), then close. */
|
|
2179
|
+
private async runFork(file: string, entryId: string): Promise<void> {
|
|
2180
|
+
const fork = this.o.actions?.forkSession;
|
|
2181
|
+
this.closeForkDialogs();
|
|
2182
|
+
if (!fork) return;
|
|
2183
|
+
await this.enter("fork", () => fork(file, entryId));
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
private closeForkDialogs(): void {
|
|
2187
|
+
this.state.mode = this.baseMode();
|
|
2188
|
+
this.selectDialog.close();
|
|
2189
|
+
this.forkTarget = undefined;
|
|
2190
|
+
this.o.requestRender();
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** y: confirm, then clone the active branch of the session under the cursor to a new file (/clone). */
|
|
2194
|
+
private confirmCloneSession(): void {
|
|
2195
|
+
if (this.refuseMultiSelect("clone")) return;
|
|
2196
|
+
const row = this.currentSessionRow();
|
|
2197
|
+
if (!row) return;
|
|
2198
|
+
if (!this.o.actions?.cloneSession) {
|
|
2199
|
+
this.setStatus(t("status.cloneUnavailable"));
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
this.cloneTarget = row;
|
|
2203
|
+
this.state.mode = "clone";
|
|
2204
|
+
this.selectDialog.open(
|
|
2205
|
+
confirmDialogSpec({
|
|
2206
|
+
title: cloneSessionTitle(),
|
|
2207
|
+
subject: this.sessionTitle(row),
|
|
2208
|
+
onConfirm: () => void this.runClone(),
|
|
2209
|
+
onCancel: () => this.closeCloneConfirm(),
|
|
2210
|
+
}),
|
|
2211
|
+
);
|
|
2212
|
+
this.o.requestRender();
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
private async runClone(): Promise<void> {
|
|
2216
|
+
const row = this.cloneTarget;
|
|
2217
|
+
const clone = this.o.actions?.cloneSession;
|
|
2218
|
+
this.closeCloneConfirm();
|
|
2219
|
+
if (!row || !clone) return;
|
|
2220
|
+
await this.enter("clone", () => clone(row.file));
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
private closeCloneConfirm(): void {
|
|
2224
|
+
this.state.mode = this.baseMode();
|
|
2225
|
+
this.selectDialog.close();
|
|
2226
|
+
this.cloneTarget = undefined;
|
|
2227
|
+
this.o.requestRender();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/** Y: copy the last assistant reply of the session under the cursor to the clipboard (/copy). */
|
|
2231
|
+
private async copyLastReply(): Promise<void> {
|
|
2232
|
+
const row = this.currentSessionRow();
|
|
2233
|
+
if (!row) return;
|
|
2234
|
+
const copy = this.o.actions?.copyLastReply;
|
|
2235
|
+
if (!copy) {
|
|
2236
|
+
this.setStatus(t("status.copyUnavailable"));
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
try {
|
|
2240
|
+
const copied = await copy(row.file);
|
|
2241
|
+
if (this.disposed) return;
|
|
2242
|
+
this.setStatus(copied ? t("status.copiedLastReply") : t("status.noReplyToCopy"));
|
|
2243
|
+
} catch (err) {
|
|
2244
|
+
this.setStatus(t("status.copyFailed", { error: (err as Error).message }));
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/** e: pick HTML / JSONL, then the output path, for the session under the cursor (/export). */
|
|
2249
|
+
private startExport(): void {
|
|
2250
|
+
if (this.refuseMultiSelect("export")) return;
|
|
2251
|
+
const row = this.currentSessionRow();
|
|
2252
|
+
if (!row) return;
|
|
2253
|
+
if (!this.o.actions?.exportSession || !this.o.actions.exportTarget) {
|
|
2254
|
+
this.setStatus(t("status.exportUnavailable"));
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
this.exportJob = { file: row.file, subject: this.sessionTitle(row) };
|
|
2258
|
+
this.openExportMenu(0);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/** The format menu with the cursor on `index` (Esc from the path prompt comes back onto the chosen format). */
|
|
2262
|
+
private openExportMenu(index: number): void {
|
|
2263
|
+
const job = this.exportJob;
|
|
2264
|
+
if (!job) return;
|
|
2265
|
+
this.state.mode = "export";
|
|
2266
|
+
this.selectDialog.open({
|
|
2267
|
+
title: exportFormatTitle(),
|
|
2268
|
+
items: exportFormats().map((f) => f.label),
|
|
2269
|
+
initialIndex: index,
|
|
2270
|
+
subject: job.subject,
|
|
2271
|
+
hints: exportFormatHints(),
|
|
2272
|
+
onSelect: (i) => {
|
|
2273
|
+
const format = exportFormats()[i]?.format;
|
|
2274
|
+
if (format) this.openExportPath(format);
|
|
2275
|
+
},
|
|
2276
|
+
onCancel: () => this.closeExportDialogs(),
|
|
2277
|
+
});
|
|
2278
|
+
this.o.requestRender();
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* The output-path prompt, pre-filled with pi's default (or `value`, what the
|
|
2283
|
+
* user typed before backing out of the overwrite confirmation).
|
|
2284
|
+
*/
|
|
2285
|
+
private openExportPath(format: ExportFormat, value?: string): void {
|
|
2286
|
+
const job = this.exportJob;
|
|
2287
|
+
const resolveTarget = this.o.actions?.exportTarget;
|
|
2288
|
+
if (!job || !resolveTarget) return;
|
|
2289
|
+
job.format = format;
|
|
2290
|
+
this.selectDialog.close();
|
|
2291
|
+
this.state.mode = "export";
|
|
2292
|
+
this.inputDialog.open({
|
|
2293
|
+
title: exportPathTitle(),
|
|
2294
|
+
// 预填 pi 的默认路径(绝对路径),用户一眼能看到会写到哪里;改成目录就在里面用默认文件名。
|
|
2295
|
+
value: value ?? resolveTarget(job.file, format, "").path,
|
|
2296
|
+
subject: job.subject,
|
|
2297
|
+
hints: exportPathHints(),
|
|
2298
|
+
onSubmit: (v) => this.submitExportPath(v),
|
|
2299
|
+
// Esc:退回格式菜单,光标停在刚选的格式上。
|
|
2300
|
+
onCancel: () => {
|
|
2301
|
+
this.inputDialog.close();
|
|
2302
|
+
this.inputDialog.focused = false;
|
|
2303
|
+
this.openExportMenu(EXPORT_FORMAT_ORDER.indexOf(format));
|
|
2304
|
+
},
|
|
2305
|
+
});
|
|
2306
|
+
this.inputDialog.focused = this._focused;
|
|
2307
|
+
this.o.requestRender();
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/** Enter in the path prompt: export right away, or ask first when a file is already there. */
|
|
2311
|
+
private submitExportPath(value: string): void {
|
|
2312
|
+
const job = this.exportJob;
|
|
2313
|
+
const format = job?.format;
|
|
2314
|
+
const resolveTarget = this.o.actions?.exportTarget;
|
|
2315
|
+
this.inputDialog.close();
|
|
2316
|
+
this.inputDialog.focused = false;
|
|
2317
|
+
if (!job || !format || !resolveTarget) {
|
|
2318
|
+
this.closeExportDialogs();
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
const target = resolveTarget(job.file, format, value);
|
|
2322
|
+
if (!target.exists) {
|
|
2323
|
+
void this.runExport(job.file, format, target.path);
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
// 覆盖已有文件是破坏性操作,先确认(CLAUDE.md 第 7 条);No / Esc 退回路径输入框,保留刚才输入的内容。
|
|
2327
|
+
this.state.mode = "export";
|
|
2328
|
+
this.selectDialog.open(
|
|
2329
|
+
confirmDialogSpec({
|
|
2330
|
+
title: overwriteFileTitle(),
|
|
2331
|
+
subject: target.path,
|
|
2332
|
+
onConfirm: () => void this.runExport(job.file, format, target.path),
|
|
2333
|
+
onCancel: () => this.openExportPath(format, value),
|
|
2334
|
+
}),
|
|
2335
|
+
);
|
|
2336
|
+
this.o.requestRender();
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/** Write the export; the panel stays open and the footer says where the file went. */
|
|
2340
|
+
private async runExport(file: string, format: ExportFormat, path: string): Promise<void> {
|
|
2341
|
+
const write = this.o.actions?.exportSession;
|
|
2342
|
+
this.closeExportDialogs();
|
|
2343
|
+
if (!write) return;
|
|
2344
|
+
this.setStatus(t("status.exporting"));
|
|
2345
|
+
try {
|
|
2346
|
+
const written = await write(file, format, path);
|
|
2347
|
+
if (this.disposed) return;
|
|
2348
|
+
this.setStatus(t("status.exportedTo", { path: written }));
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
if (this.disposed) return;
|
|
2351
|
+
this.setStatus(t("status.exportFailed", { error: (err as Error).message }));
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
private closeExportDialogs(): void {
|
|
2356
|
+
this.state.mode = this.baseMode();
|
|
2357
|
+
this.selectDialog.close();
|
|
2358
|
+
this.inputDialog.close();
|
|
2359
|
+
this.inputDialog.focused = false;
|
|
2360
|
+
this.exportJob = undefined;
|
|
2361
|
+
this.o.requestRender();
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/** I: ask for the JSONL to import (`value` = what was typed before backing out of the confirmation). */
|
|
2365
|
+
private openImportInput(value: string): void {
|
|
2366
|
+
if (!this.o.actions?.importSession) {
|
|
2367
|
+
this.setStatus(t("status.importUnavailable"));
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
this.selectDialog.close();
|
|
2371
|
+
this.state.mode = "import";
|
|
2372
|
+
this.inputDialog.open({
|
|
2373
|
+
title: importDialogTitle(),
|
|
2374
|
+
value,
|
|
2375
|
+
subject: importDialogSubject(),
|
|
2376
|
+
hints: importDialogHints(),
|
|
2377
|
+
onSubmit: (v) => this.confirmImport(v),
|
|
2378
|
+
onCancel: () => this.closeImportDialogs(),
|
|
2379
|
+
});
|
|
2380
|
+
this.inputDialog.focused = this._focused;
|
|
2381
|
+
this.o.requestRender();
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/** Enter in the import prompt: confirm like pi's /import ("Replace current session with …?"). */
|
|
2385
|
+
private confirmImport(value: string): void {
|
|
2386
|
+
this.inputDialog.close();
|
|
2387
|
+
this.inputDialog.focused = false;
|
|
2388
|
+
const path = value.trim();
|
|
2389
|
+
if (!path) {
|
|
2390
|
+
this.closeImportDialogs();
|
|
2391
|
+
this.setStatus(t("status.importNoFile"));
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
this.state.mode = "import";
|
|
2395
|
+
this.selectDialog.open(
|
|
2396
|
+
confirmDialogSpec({
|
|
2397
|
+
title: importSessionTitle(),
|
|
2398
|
+
subject: path,
|
|
2399
|
+
onConfirm: () => void this.runImport(path),
|
|
2400
|
+
// Esc / No:退回输入框,保留刚才输入的路径。
|
|
2401
|
+
onCancel: () => this.openImportInput(value),
|
|
2402
|
+
}),
|
|
2403
|
+
);
|
|
2404
|
+
this.o.requestRender();
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/** Confirmed: copy the file into the session folder and switch to it, closing the panel via `enter()`. */
|
|
2408
|
+
private async runImport(input: string): Promise<void> {
|
|
2409
|
+
const load = this.o.actions?.importSession;
|
|
2410
|
+
this.closeImportDialogs();
|
|
2411
|
+
if (!load) return;
|
|
2412
|
+
await this.enter("import", () => load(input));
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
private closeImportDialogs(): void {
|
|
2416
|
+
this.state.mode = this.baseMode();
|
|
2417
|
+
this.selectDialog.close();
|
|
2418
|
+
this.inputDialog.close();
|
|
2419
|
+
this.inputDialog.focused = false;
|
|
2420
|
+
this.o.requestRender();
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/** S: confirm (the session leaves the machine), then upload it as a secret gist (/share). */
|
|
2424
|
+
private confirmShareSession(): void {
|
|
2425
|
+
if (this.refuseMultiSelect("share")) return;
|
|
2426
|
+
const row = this.currentSessionRow();
|
|
2427
|
+
if (!row) return;
|
|
2428
|
+
if (!this.o.actions?.shareSession) {
|
|
2429
|
+
this.setStatus(t("status.shareUnavailable"));
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
this.state.mode = "share";
|
|
2433
|
+
this.selectDialog.open(
|
|
2434
|
+
confirmDialogSpec({
|
|
2435
|
+
title: shareSessionTitle(),
|
|
2436
|
+
subject: this.sessionTitle(row),
|
|
2437
|
+
onConfirm: () => void this.runShare(row.file),
|
|
2438
|
+
onCancel: () => this.closeShareConfirm(),
|
|
2439
|
+
}),
|
|
2440
|
+
);
|
|
2441
|
+
this.o.requestRender();
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/** Upload, then put the viewer link on the clipboard (a long link may not fit the footer) and show it. */
|
|
2445
|
+
private async runShare(file: string): Promise<void> {
|
|
2446
|
+
const share = this.o.actions?.shareSession;
|
|
2447
|
+
this.closeShareConfirm();
|
|
2448
|
+
if (!share) return;
|
|
2449
|
+
this.setStatus(t("status.sharing"));
|
|
2450
|
+
let result: ShareResult;
|
|
2451
|
+
try {
|
|
2452
|
+
result = await share(file);
|
|
2453
|
+
if (this.disposed) return;
|
|
2454
|
+
} catch (err) {
|
|
2455
|
+
if (this.disposed) return;
|
|
2456
|
+
this.setStatus(t("status.shareFailed", { error: (err as Error).message }));
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
const copy = this.o.actions?.copyText;
|
|
2460
|
+
try {
|
|
2461
|
+
if (!copy) throw new Error("no clipboard");
|
|
2462
|
+
await copy(result.url);
|
|
2463
|
+
if (this.disposed) return;
|
|
2464
|
+
this.setStatus(t("status.shareUrlCopied", { url: result.url }));
|
|
2465
|
+
} catch {
|
|
2466
|
+
if (this.disposed) return;
|
|
2467
|
+
this.setStatus(t("status.shared", { url: result.url }));
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
private closeShareConfirm(): void {
|
|
2472
|
+
this.state.mode = this.baseMode();
|
|
2473
|
+
this.selectDialog.close();
|
|
2474
|
+
this.o.requestRender();
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
/** Rows a centered menu may show at once before it scrolls: leave room for borders + footer. */
|
|
2478
|
+
private dialogMaxRows(): number {
|
|
2479
|
+
return Math.max(1, this.o.getHeight() - 6);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** s: the next sort order (recent → created → title → threaded → …); the cursor follows its session. */
|
|
2483
|
+
private async cycleSort(): Promise<void> {
|
|
2484
|
+
const i = SESSION_SORT_MODES.indexOf(this.state.sort);
|
|
2485
|
+
const next = SESSION_SORT_MODES[(i + 1) % SESSION_SORT_MODES.length]!;
|
|
2486
|
+
this.state.sort = next;
|
|
2487
|
+
const keep = this.sessions[this.state.cursor.sessions]?.file;
|
|
2488
|
+
if (await this.listSessions(keep)) this.setStatus(t("status.sort", { sort: t(`sort.${next}`) }));
|
|
2489
|
+
await this.followSessionsCursor();
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/** i: load what /session shows for the session under the cursor and open the info box. */
|
|
2493
|
+
private async openSessionInfo(): Promise<void> {
|
|
2494
|
+
const row = this.currentSessionRow();
|
|
2495
|
+
if (!row) return;
|
|
2496
|
+
const load = this.o.data.loadSessionInfo;
|
|
2497
|
+
if (!load) {
|
|
2498
|
+
this.setStatus(t("status.sessionInfoUnavailable"));
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
let info: SessionInfo | undefined;
|
|
2502
|
+
try {
|
|
2503
|
+
info = await load(row.file);
|
|
2504
|
+
} catch (err) {
|
|
2505
|
+
this.setStatus(t("status.sessionInfoFailed", { error: (err as Error).message }));
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
if (this.disposed) return;
|
|
2509
|
+
if (!info) {
|
|
2510
|
+
this.setStatus(t("status.sessionInfoCannotRead", { file: row.file }));
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
this.state.mode = "info";
|
|
2514
|
+
this.infoDialog.open({
|
|
2515
|
+
info,
|
|
2516
|
+
onCopy: (text) => void this.copySessionInfo(text),
|
|
2517
|
+
onClose: () => this.closeSessionInfo(),
|
|
2518
|
+
});
|
|
2519
|
+
this.o.requestRender();
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
/** y in the info box: the whole text goes to the clipboard, the box stays open. */
|
|
2523
|
+
private async copySessionInfo(text: string): Promise<void> {
|
|
2524
|
+
const copy = this.o.actions?.copyText;
|
|
2525
|
+
if (!copy) {
|
|
2526
|
+
this.setStatus(t("status.copyUnavailable"));
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
try {
|
|
2530
|
+
await copy(text);
|
|
2531
|
+
if (this.disposed) return;
|
|
2532
|
+
this.setStatus(t("status.copiedSessionInfo"));
|
|
2533
|
+
} catch (err) {
|
|
2534
|
+
this.setStatus(t("status.copyFailed", { error: (err as Error).message }));
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
private closeSessionInfo(): void {
|
|
2539
|
+
this.state.mode = this.baseMode();
|
|
2540
|
+
this.infoDialog.close();
|
|
2541
|
+
this.o.requestRender();
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// -----------------------------------------------------------------------
|
|
2545
|
+
// Enter: resume a session / restore to a tree node
|
|
2546
|
+
// -----------------------------------------------------------------------
|
|
2547
|
+
|
|
2548
|
+
/** Enter in SESSIONS: switch pi to the session under the cursor (/resume), then close. */
|
|
2549
|
+
private async resumeSession(): Promise<void> {
|
|
2550
|
+
const row = this.sessions[this.state.cursor.sessions];
|
|
2551
|
+
if (!row) {
|
|
2552
|
+
this.setStatus(t("status.noSessionSelected"));
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
const actions = this.o.actions;
|
|
2556
|
+
if (!actions) {
|
|
2557
|
+
this.setStatus(t("status.resumeUnavailable"));
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
await this.enter("resume", () => actions.resumeSession(row.file));
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
/**
|
|
2564
|
+
* Enter in TREE: continue from the node under the cursor (/tree restore), then close.
|
|
2565
|
+
*
|
|
2566
|
+
* 和 pi 内置 /tree 一样先问怎么处理被放弃的分支(Summarize branch? 菜单);光标就在
|
|
2567
|
+
* 活动叶子上时 Enter 等于直接进入该会话,不问;pi 的 branchSummary.skipPrompt 打开时也不问。
|
|
2568
|
+
*/
|
|
2569
|
+
private restoreTreeNode(target: TreeTarget | undefined): void {
|
|
2570
|
+
if (!target) return;
|
|
2571
|
+
if (!this.o.actions) {
|
|
2572
|
+
this.setStatus(t("status.restoreUnavailable"));
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const restore: RestoreTarget = {
|
|
2576
|
+
file: target.file,
|
|
2577
|
+
entryId: target.row.entryId,
|
|
2578
|
+
subject: `${target.row.role}: ${target.row.text}`,
|
|
2579
|
+
};
|
|
2580
|
+
if (target.row.isLeaf || this.o.skipSummaryPrompt) {
|
|
2581
|
+
void this.runRestore(restore, { summarize: false });
|
|
2582
|
+
return;
|
|
2583
|
+
}
|
|
2584
|
+
this.openSummaryMenu(restore, 0);
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
/** The three-way menu of /tree; `index` is where the cursor starts (Esc from the custom prompt comes back onto that entry). */
|
|
2588
|
+
private openSummaryMenu(target: RestoreTarget, index: number): void {
|
|
2589
|
+
this.restoreTarget = target;
|
|
2590
|
+
this.state.mode = "restore";
|
|
2591
|
+
this.selectDialog.open({
|
|
2592
|
+
title: summaryMenuTitle(),
|
|
2593
|
+
items: summaryMenu().map((m) => m.label),
|
|
2594
|
+
initialIndex: index,
|
|
2595
|
+
subject: target.subject,
|
|
2596
|
+
hints: summaryMenuHints(),
|
|
2597
|
+
onSelect: (i) => this.chooseSummary(i),
|
|
2598
|
+
// Esc:退回 tree 面板,什么都不做(pi 是退回 tree 选择器)。
|
|
2599
|
+
onCancel: () => this.closeRestoreDialogs(),
|
|
2600
|
+
});
|
|
2601
|
+
this.o.requestRender();
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
/** Enter in the menu: restore right away, or ask for the custom instructions first. */
|
|
2605
|
+
private chooseSummary(index: number): void {
|
|
2606
|
+
const target = this.restoreTarget;
|
|
2607
|
+
const choice = SUMMARY_CHOICES[index];
|
|
2608
|
+
this.closeRestoreDialogs();
|
|
2609
|
+
if (!target || !choice) return;
|
|
2610
|
+
switch (choice) {
|
|
2611
|
+
case "none":
|
|
2612
|
+
void this.runRestore(target, { summarize: false });
|
|
2613
|
+
return;
|
|
2614
|
+
case "summarize":
|
|
2615
|
+
void this.runRestore(target, { summarize: true });
|
|
2616
|
+
return;
|
|
2617
|
+
case "custom":
|
|
2618
|
+
this.openCustomPrompt(target);
|
|
2619
|
+
return;
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/** "Summarize with custom prompt": a one-line prompt for the summarizer instructions (pi uses a multi-line editor). */
|
|
2624
|
+
private openCustomPrompt(target: RestoreTarget): void {
|
|
2625
|
+
this.restoreTarget = target;
|
|
2626
|
+
this.state.mode = "restore";
|
|
2627
|
+
this.inputDialog.open({
|
|
2628
|
+
title: customPromptTitle(),
|
|
2629
|
+
subject: target.subject,
|
|
2630
|
+
hints: customPromptHints(),
|
|
2631
|
+
onSubmit: (v) => this.submitCustomPrompt(v),
|
|
2632
|
+
// Esc:退回三选菜单,光标停在 custom prompt 那一项,和 pi 一致。
|
|
2633
|
+
onCancel: () => {
|
|
2634
|
+
this.closeRestoreDialogs();
|
|
2635
|
+
this.openSummaryMenu(target, CUSTOM_PROMPT_INDEX);
|
|
2636
|
+
},
|
|
2637
|
+
});
|
|
2638
|
+
this.inputDialog.focused = this._focused;
|
|
2639
|
+
this.o.requestRender();
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
/** Enter in the custom prompt: summarize with the instructions (blank = pi's default prompt). */
|
|
2643
|
+
private submitCustomPrompt(value: string): void {
|
|
2644
|
+
const target = this.restoreTarget;
|
|
2645
|
+
this.closeRestoreDialogs();
|
|
2646
|
+
if (!target) return;
|
|
2647
|
+
const instructions = value.trim();
|
|
2648
|
+
void this.runRestore(target, instructions ? { summarize: true, customInstructions: instructions } : { summarize: true });
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
private closeRestoreDialogs(): void {
|
|
2652
|
+
this.state.mode = this.baseMode();
|
|
2653
|
+
this.selectDialog.close();
|
|
2654
|
+
this.inputDialog.close();
|
|
2655
|
+
this.inputDialog.focused = false;
|
|
2656
|
+
this.restoreTarget = undefined;
|
|
2657
|
+
this.o.requestRender();
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
/** Hand the choice to the actions layer; a summary takes a while, so the footer says so meanwhile. */
|
|
2661
|
+
private async runRestore(target: RestoreTarget, options: RestoreOptions): Promise<void> {
|
|
2662
|
+
const actions = this.o.actions;
|
|
2663
|
+
if (!actions) return;
|
|
2664
|
+
await this.enter(
|
|
2665
|
+
"restore",
|
|
2666
|
+
() => actions.restoreNode(target.file, target.entryId, options),
|
|
2667
|
+
options.summarize ? t("status.summarizing") : undefined,
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* Run an action that hands control back to pi.
|
|
2673
|
+
*
|
|
2674
|
+
* 等待期间把面板藏起来并忽略按键:pi 切换时可能自己弹提示(比如会话目录不存在要不要
|
|
2675
|
+
* 继续),藏起来它才看得见、按键才到得了它。成功就关闭面板;失败把原因写进 footer,
|
|
2676
|
+
* 面板重新显示出来。`progress` 是等待期间 footer 的文字,默认 `${what}…`。
|
|
2677
|
+
*/
|
|
2678
|
+
private async enter(what: string, run: () => Promise<EnterOutcome>, progress?: string): Promise<void> {
|
|
2679
|
+
if (this.entering) return;
|
|
2680
|
+
this.entering = true;
|
|
2681
|
+
const verb = t(`enter.${what}`);
|
|
2682
|
+
this.setStatus(progress ?? t("status.working", { what: verb }));
|
|
2683
|
+
this.o.setHidden?.(true);
|
|
2684
|
+
try {
|
|
2685
|
+
await run();
|
|
2686
|
+
if (this.disposed) return;
|
|
2687
|
+
this.close();
|
|
2688
|
+
} catch (err) {
|
|
2689
|
+
if (this.disposed) return;
|
|
2690
|
+
this.o.setHidden?.(false);
|
|
2691
|
+
this.setStatus(t("status.workFailed", { what: verb, error: (err as Error).message }));
|
|
2692
|
+
} finally {
|
|
2693
|
+
this.entering = false;
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// -----------------------------------------------------------------------
|
|
2698
|
+
// Lifecycle
|
|
2699
|
+
// -----------------------------------------------------------------------
|
|
2700
|
+
|
|
2701
|
+
private close(): void {
|
|
2702
|
+
if (this.disposed) return;
|
|
2703
|
+
this.disposed = true;
|
|
2704
|
+
this.clearPending();
|
|
2705
|
+
this.clearSessionLoad();
|
|
2706
|
+
this.o.onClose();
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
dispose(): void {
|
|
2710
|
+
this.disposed = true;
|
|
2711
|
+
this.stopChangelogSpinner();
|
|
2712
|
+
this.clearPending();
|
|
2713
|
+
this.clearSessionLoad();
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
private clearSessionLoad(): void {
|
|
2717
|
+
if (this.sessionLoadTimer) clearTimeout(this.sessionLoadTimer);
|
|
2718
|
+
this.sessionLoadTimer = undefined;
|
|
2719
|
+
this.sessionLoadResolve?.();
|
|
2720
|
+
this.sessionLoadResolve = undefined;
|
|
2721
|
+
this.sessionLoadPromise = undefined;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
invalidate(): void {
|
|
2725
|
+
// Nothing cached across renders yet.
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// -----------------------------------------------------------------------
|
|
2729
|
+
// Render
|
|
2730
|
+
// -----------------------------------------------------------------------
|
|
2731
|
+
|
|
2732
|
+
render(width: number): string[] {
|
|
2733
|
+
this.lastWidth = width;
|
|
2734
|
+
const height = Math.max(8, this.o.getHeight());
|
|
2735
|
+
const { leftW, rightW, bodyH, sessionsH, treeH } = panelGeometry(width, height, this.ratio);
|
|
2736
|
+
const { theme } = this.o;
|
|
2737
|
+
const selectedSession = this.sessions[this.state.cursor.sessions];
|
|
2738
|
+
const sessionsSearch = this.searchView("sessions");
|
|
2739
|
+
const treeSearch = this.searchView("tree");
|
|
2740
|
+
// 列表窗口的首行:滚轮滚动时用独立偏移,否则按光标居中(和 hitTarget 一致)。
|
|
2741
|
+
const visibleRows = listVisibleRows(height);
|
|
2742
|
+
|
|
2743
|
+
const left = [
|
|
2744
|
+
...renderSessionsPane(
|
|
2745
|
+
{
|
|
2746
|
+
rows: this.sessions,
|
|
2747
|
+
cursor: this.state.cursor.sessions,
|
|
2748
|
+
first: this.listFirst("sessions", visibleRows.sessions),
|
|
2749
|
+
focused: this.state.focus === "sessions",
|
|
2750
|
+
scope: this.state.scope,
|
|
2751
|
+
sort: this.state.sort,
|
|
2752
|
+
selected: this.state.selectedSessionFiles,
|
|
2753
|
+
title: this.paneTitle("sessions"),
|
|
2754
|
+
theme,
|
|
2755
|
+
...(sessionsSearch ? { search: sessionsSearch } : {}),
|
|
2756
|
+
},
|
|
2757
|
+
leftW,
|
|
2758
|
+
sessionsH,
|
|
2759
|
+
),
|
|
2760
|
+
...renderTreePane(
|
|
2761
|
+
{
|
|
2762
|
+
rows: this.visibleTree,
|
|
2763
|
+
outline: this.treeOutline,
|
|
2764
|
+
cursor: this.state.cursor.tree,
|
|
2765
|
+
first: this.listFirst("tree", visibleRows.tree),
|
|
2766
|
+
focused: this.state.focus === "tree",
|
|
2767
|
+
filter: this.state.treeFilter,
|
|
2768
|
+
emptyMessage: this.emptyMessage(selectedSession),
|
|
2769
|
+
title: this.paneTitle("tree"),
|
|
2770
|
+
theme,
|
|
2771
|
+
...(treeSearch ? { search: treeSearch } : {}),
|
|
2772
|
+
},
|
|
2773
|
+
leftW,
|
|
2774
|
+
treeH,
|
|
2775
|
+
),
|
|
2776
|
+
];
|
|
2777
|
+
|
|
2778
|
+
// 先排版(缓存按宽度失效),CONTENT 的搜索结果跟着这份排版算。
|
|
2779
|
+
const layout = this.contentLayoutFor(rightW - 2, bodyH - 2);
|
|
2780
|
+
const contentSearch = this.searchView("content");
|
|
2781
|
+
const right = renderContentPane(
|
|
2782
|
+
{
|
|
2783
|
+
blocks: this.content,
|
|
2784
|
+
layout,
|
|
2785
|
+
scroll: this.state.cursor.content,
|
|
2786
|
+
focused: this.state.focus === "content",
|
|
2787
|
+
emptyMessage: this.emptyMessage(selectedSession),
|
|
2788
|
+
title: this.paneTitle("content"),
|
|
2789
|
+
theme,
|
|
2790
|
+
...(this.state.contentHighlight ? { highlightEntryId: this.state.contentHighlight } : {}),
|
|
2791
|
+
...(contentSearch ? { search: contentSearch } : {}),
|
|
2792
|
+
},
|
|
2793
|
+
rightW,
|
|
2794
|
+
bodyH,
|
|
2795
|
+
);
|
|
2796
|
+
|
|
2797
|
+
let lines = sideBySide(left, right, leftW, rightW);
|
|
2798
|
+
// 弹窗按层叠顺序画:树对话框 → 输入框 / 菜单(可以开在树对话框上面)→ 帮助。
|
|
2799
|
+
if (this.treeDialog.isOpen) {
|
|
2800
|
+
lines = this.treeDialog.overlay(lines, width);
|
|
2801
|
+
}
|
|
2802
|
+
if (this.inputDialog.isOpen) {
|
|
2803
|
+
lines = this.inputDialog.overlay(lines, width);
|
|
2804
|
+
}
|
|
2805
|
+
if (this.selectDialog.isOpen) {
|
|
2806
|
+
lines = this.selectDialog.overlay(lines, width);
|
|
2807
|
+
}
|
|
2808
|
+
if (this.infoDialog.isOpen) {
|
|
2809
|
+
lines = this.infoDialog.overlay(lines, width);
|
|
2810
|
+
}
|
|
2811
|
+
if (this.changelogDialog.isOpen) {
|
|
2812
|
+
lines = this.changelogDialog.overlay(lines, width);
|
|
2813
|
+
}
|
|
2814
|
+
if (this.state.helpOpen) {
|
|
2815
|
+
lines = overlayHelp(lines, { keymap: this.keymap, focus: this.state.focus, scroll: this.state.helpScroll, theme }, width);
|
|
2816
|
+
}
|
|
2817
|
+
return [...lines, this.renderBottom(width)].map((l) => fit(l, width));
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
/** Footer row: search bar while typing, the open dialog's keys, the search status of the focused pane, otherwise hints. */
|
|
2821
|
+
private renderBottom(width: number): string {
|
|
2822
|
+
if (this.state.mode === "search") {
|
|
2823
|
+
return this.searchBar.render(width)[0] ?? "";
|
|
2824
|
+
}
|
|
2825
|
+
const footer = {
|
|
2826
|
+
mode: this.state.mode,
|
|
2827
|
+
focus: this.state.focus,
|
|
2828
|
+
keymap: this.keymap,
|
|
2829
|
+
scope: this.state.scope,
|
|
2830
|
+
theme: this.o.theme,
|
|
2831
|
+
// 版本号常驻在 footer 最右侧(搜索状态行除外)。
|
|
2832
|
+
...(this.o.version ? { version: this.o.version } : {}),
|
|
2833
|
+
};
|
|
2834
|
+
const pendingHint = this.pending.length ? t("status.pending", { keys: this.pending.join("") }) : undefined;
|
|
2835
|
+
const status = pendingHint ?? this.status;
|
|
2836
|
+
// 弹窗打开时 footer 只显示弹窗自己的按键提示(如 Enter save / Esc cancel / empty removes);状态文字照常显示。
|
|
2837
|
+
const dialog = this.inputDialog.isOpen
|
|
2838
|
+
? this.inputDialog
|
|
2839
|
+
: this.selectDialog.isOpen
|
|
2840
|
+
? this.selectDialog
|
|
2841
|
+
: this.infoDialog.isOpen
|
|
2842
|
+
? this.infoDialog
|
|
2843
|
+
: this.changelogDialog.isOpen
|
|
2844
|
+
? this.changelogDialog
|
|
2845
|
+
: this.treeDialog.isOpen
|
|
2846
|
+
? this.treeDialog
|
|
2847
|
+
: undefined;
|
|
2848
|
+
if (dialog) {
|
|
2849
|
+
return renderFooter({ ...footer, hints: dialog.hints, ...(status ? { status } : {}) }, width)[0]!;
|
|
2850
|
+
}
|
|
2851
|
+
// 当前面板有搜索生效:显示关键字、位置 / 数量和 n / N / Esc 提示(别的面板的搜索不显示)。
|
|
2852
|
+
const search = this.state.search[this.state.focus];
|
|
2853
|
+
const view = search ? this.searchView(this.state.focus) : undefined;
|
|
2854
|
+
if (search && view) {
|
|
2855
|
+
return renderSearchStatus(
|
|
2856
|
+
{ query: search.query, position: view.position, total: view.total, hints: this.searchHints(), theme: this.o.theme, ...(status ? { status } : {}) },
|
|
2857
|
+
width,
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
return renderFooter({ ...footer, ...(status ? { status } : {}) }, width)[0]!;
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
private emptyMessage(selected: SessionRow | undefined): string {
|
|
2864
|
+
if (!selected) return t("pane.selectSession");
|
|
2865
|
+
if (this.loadedSessionFile !== selected.file) return t("pane.loading");
|
|
2866
|
+
return t("pane.nothingToShow");
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
/** "[1] SESSIONS": the jump key comes from the resolved keymap, so rebinding shows up here. */
|
|
2870
|
+
private paneTitle(pane: PaneId): string {
|
|
2871
|
+
const key = labelsFor(this.keymap, "global", FOCUS_ACTIONS[pane])[0];
|
|
2872
|
+
return key ? `[${key}] ${paneTitleText(pane)}` : paneTitleText(pane);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
function findLastIndex<T>(arr: T[], pred: (t: T) => boolean): number {
|
|
2877
|
+
for (let i = arr.length - 1; i >= 0; i--) if (pred(arr[i]!)) return i;
|
|
2878
|
+
return -1;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/** Indices of the elements `pred` accepts, ascending. */
|
|
2882
|
+
function indicesWhere<T>(arr: T[], pred: (t: T, i: number) => boolean): number[] {
|
|
2883
|
+
const out: number[] = [];
|
|
2884
|
+
arr.forEach((t, i) => {
|
|
2885
|
+
if (pred(t, i)) out.push(i);
|
|
2886
|
+
});
|
|
2887
|
+
return out;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function clamp(n: number, min: number, max: number): number {
|
|
2891
|
+
return Math.max(min, Math.min(max, n));
|
|
2892
|
+
}
|