@neosh/archive 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/main.ts +1340 -0
- package/package.json +21 -0
- package/plugin.toml +4 -0
package/main.ts
ADDED
|
@@ -0,0 +1,1340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The archive: what you have finished with, and how you finally get rid of it.
|
|
3
|
+
*
|
|
4
|
+
* The workspace has two verbs — `x` puts a conversation away, `X` destroys it — and the archive
|
|
5
|
+
* was taken out of the sidebar, because a list of things you are deliberately done with
|
|
6
|
+
* is the one part of that column that is never the answer. Both decisions then said the same thing
|
|
7
|
+
* about the other end: nothing sweeps it, an archive is a place things accumulate, and deleting
|
|
8
|
+
* somebody's history on a timer is a move this workspace has never been willing to make.
|
|
9
|
+
*
|
|
10
|
+
* That is still true and this plugin does not change it. What it changes is the half those
|
|
11
|
+
* decisions left as a note: **emptying it by hand was one conversation at a time**. A picker with `^X` on a
|
|
12
|
+
* row is a fine way to throw away the thing you just regretted and a terrible way to deal with two
|
|
13
|
+
* hundred of them, which is what an archive of a workspace you have used for a year actually is.
|
|
14
|
+
*
|
|
15
|
+
* So the archive is a panel rather than a picker, and everything a panel is owed it has:
|
|
16
|
+
*
|
|
17
|
+
* - **Every key is an ordinary binding** on the buffer kind `neosh.archive`, pointed at a command
|
|
18
|
+
* with a name. `^Z` lists them, `^K` runs them, one line in `init.ts` moves any of them. There is
|
|
19
|
+
* no private switch on `KeyContext` here — the picker's `ownKeys`/`onKey` pair, which is what
|
|
20
|
+
* this replaced, is exactly the shape the surface rule exists to stop.
|
|
21
|
+
* - **`archive.action` is a contribution point.** Say a key, a label and a command, and it is a
|
|
22
|
+
* verb on these rows — invoked with the marked conversations, or with the row under the cursor
|
|
23
|
+
* when nothing is marked, so a plugin never has to track the cursor itself.
|
|
24
|
+
* - **The rows are a list you move in**: `j`/`k` take a count, `^D`/`^U` are half of
|
|
25
|
+
* the panel's real height, `12G` is a row, and a half-typed count is on screen.
|
|
26
|
+
*
|
|
27
|
+
* And it can be marked. `<Space>` ticks a row, `a` ticks everything showing, and every verb here —
|
|
28
|
+
* put back, delete, copy — acts on the ticked set or, when nothing is ticked, on the row under the
|
|
29
|
+
* cursor. `^X` empties the archive outright: everything *currently listed*, which is the filter's
|
|
30
|
+
* whole point, so narrowing to one project and pressing it deletes that project's finished
|
|
31
|
+
* conversations and nothing else. It asks, it says how many and how much is in them, and the
|
|
32
|
+
* question is charged for the one reason that matters — it cannot be undone.
|
|
33
|
+
*
|
|
34
|
+
* Two things here are about conversations you have never seen. `RESTORE_LIMIT` means a workspace
|
|
35
|
+
* loads the most recent few hundred conversations and leaves the rest on disk, in no list at all —
|
|
36
|
+
* so an archive built on `session.list` would have reported a number that was not the number and
|
|
37
|
+
* emptied itself down to a directory that was still full. It reads `session.stored` as well, which
|
|
38
|
+
* is the directory, and the host brings one in from disk the moment any verb names it.
|
|
39
|
+
*
|
|
40
|
+
* Nothing here deletes on a timer. `archive.auto_days` will *archive* an idle conversation, because
|
|
41
|
+
* archiving is reversible and free and that is the test that matters; `archive.retention_days` only
|
|
42
|
+
* ever says how much is old, and `archive.sweep` is the key you press when you agree. A reminder is
|
|
43
|
+
* not a policy.
|
|
44
|
+
*
|
|
45
|
+
* It is a plugin. `plugins.disabled = ["archive"]` turns it off, and an archive of your own that
|
|
46
|
+
* loads afterwards and binds `^F` wins.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { byteLength } from "@neosh/api";
|
|
50
|
+
import type {
|
|
51
|
+
BufferId,
|
|
52
|
+
Disposable,
|
|
53
|
+
Message,
|
|
54
|
+
Neosh,
|
|
55
|
+
PluginContext,
|
|
56
|
+
SessionInfo,
|
|
57
|
+
WindowId,
|
|
58
|
+
} from "@neosh/api";
|
|
59
|
+
import { confirmDestructive, CursoredList, type ListRow, prompt } from "@neosh/api/ui";
|
|
60
|
+
|
|
61
|
+
const NS = "neosh.archive";
|
|
62
|
+
/** What this panel's buffer says it is. Everything a third party binds or finds hangs off this. */
|
|
63
|
+
const KIND = "neosh.archive";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A verb on these rows, contributed by somebody else.
|
|
67
|
+
*
|
|
68
|
+
* The same shape `sidebar.action` takes, and for the same reason: a key press carries no arguments
|
|
69
|
+
* of its own, so the command is invoked with the conversations it is about — every marked one, or
|
|
70
|
+
* the row under the cursor when nothing is marked. A plugin that had to track the cursor separately
|
|
71
|
+
* would be one race away from deleting the wrong thing.
|
|
72
|
+
*/
|
|
73
|
+
const POINT_ACTION = "archive.action";
|
|
74
|
+
|
|
75
|
+
interface ActionItem {
|
|
76
|
+
/** Key notation, as `keymap.set` takes it: `d`, `<C-y>`, `gd`. */
|
|
77
|
+
key: string;
|
|
78
|
+
/** What it does, for the hint strip and for `^Z`. */
|
|
79
|
+
label: string;
|
|
80
|
+
command: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What a row points at, so the cursor survives the list being rebuilt underneath it. */
|
|
84
|
+
type Target = { kind: "session"; id: string };
|
|
85
|
+
|
|
86
|
+
/** How the list is ordered. The default is when you put it away, which is how you remember it. */
|
|
87
|
+
type Sort = "archived" | "used" | "name" | "project" | "size";
|
|
88
|
+
/** What the headings are, if there are any. */
|
|
89
|
+
type Group = "none" | "project" | "age";
|
|
90
|
+
|
|
91
|
+
const SORTS: Sort[] = ["archived", "used", "name", "project", "size"];
|
|
92
|
+
const GROUPS: Group[] = ["project", "age", "none"];
|
|
93
|
+
|
|
94
|
+
const SORT_LABEL: Record<Sort, string> = {
|
|
95
|
+
archived: "put away",
|
|
96
|
+
used: "last used",
|
|
97
|
+
name: "name",
|
|
98
|
+
project: "project",
|
|
99
|
+
size: "size",
|
|
100
|
+
};
|
|
101
|
+
const GROUP_LABEL: Record<Group, string> = {
|
|
102
|
+
none: "flat",
|
|
103
|
+
project: "by project",
|
|
104
|
+
age: "by age",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export async function activate({ neosh, subscriptions }: PluginContext) {
|
|
108
|
+
await declareOptions(neosh);
|
|
109
|
+
|
|
110
|
+
const panel = new Panel(neosh);
|
|
111
|
+
subscriptions.push({ dispose: () => void panel.close() });
|
|
112
|
+
|
|
113
|
+
await registerCommands(neosh, subscriptions, panel);
|
|
114
|
+
await bindKeys(neosh, subscriptions, panel);
|
|
115
|
+
await contributeToSidebar(neosh, subscriptions);
|
|
116
|
+
|
|
117
|
+
// Housekeeping, once the workspace has settled rather than in the middle of starting it: the
|
|
118
|
+
// first thing a new terminal does is draw a transcript, and a notice that lands before there is
|
|
119
|
+
// anywhere to put it is a notice nobody sees. Then daily, because both of the questions it asks
|
|
120
|
+
// are about days.
|
|
121
|
+
//
|
|
122
|
+
// Not held in `subscriptions` for its own sake — the runtime cancels a plugin's timers when it
|
|
123
|
+
// unloads — but the count row it refreshes is, and that is what the sidebar reads.
|
|
124
|
+
neosh.timer.after(4000, () => void housekeeping(neosh));
|
|
125
|
+
subscriptions.push(neosh.timer.every(6 * 60 * 60 * 1000, () => void housekeeping(neosh)));
|
|
126
|
+
|
|
127
|
+
// The count in the sidebar follows the conversations rather than whoever happened to change one.
|
|
128
|
+
//
|
|
129
|
+
// Both halves are needed. `session.onChange` is *switching*, and archiving something you are not
|
|
130
|
+
// looking at does not switch anything — so the door would appear a redraw late, or not at all.
|
|
131
|
+
// The poll is the same period the panel beside it already redraws on, and it costs one `list`
|
|
132
|
+
// call: the expensive half of the count, what is on disk and not loaded, cannot change without
|
|
133
|
+
// this process being told. Nothing is contributed unless the number moved, because a contribution
|
|
134
|
+
// is a broadcast and re-announcing an unchanged row would redraw every panel reading this point.
|
|
135
|
+
const period = (await neosh.opt.get<number>("sidebar.refresh_ms").catch(() => 4000)) ?? 4000;
|
|
136
|
+
subscriptions.push(neosh.session.onChange(() => void refreshSidebarRow(neosh)));
|
|
137
|
+
subscriptions.push(neosh.timer.every(period, () => void refreshSidebarRow(neosh)));
|
|
138
|
+
subscriptions.push(neosh.opt.onChange((e) => {
|
|
139
|
+
if (e.name === "archive.sidebar") void refreshSidebarRow(neosh);
|
|
140
|
+
}));
|
|
141
|
+
await refreshSidebarRow(neosh);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function declareOptions(neosh: Neosh): Promise<void> {
|
|
145
|
+
await neosh.opt.declare({
|
|
146
|
+
name: "archive.sort",
|
|
147
|
+
type: { type: "enum", values: SORTS },
|
|
148
|
+
default: "archived",
|
|
149
|
+
description:
|
|
150
|
+
"How the archive is ordered: when you put it away, when you last used it, its name, its project, or how much is in it. `s` in the panel cycles it.",
|
|
151
|
+
});
|
|
152
|
+
await neosh.opt.declare({
|
|
153
|
+
name: "archive.group",
|
|
154
|
+
type: { type: "enum", values: GROUPS },
|
|
155
|
+
default: "project",
|
|
156
|
+
description:
|
|
157
|
+
"What the headings in the archive are: the project each conversation came from, how long ago it was put away, or nothing at all. `S` in the panel cycles it.",
|
|
158
|
+
});
|
|
159
|
+
await neosh.opt.declare({
|
|
160
|
+
name: "archive.width",
|
|
161
|
+
type: { type: "int", min: 40, max: 200 },
|
|
162
|
+
default: 92,
|
|
163
|
+
description: "How wide the archive panel is, at most. It is clamped to the terminal.",
|
|
164
|
+
});
|
|
165
|
+
await neosh.opt.declare({
|
|
166
|
+
name: "archive.height",
|
|
167
|
+
type: { type: "int", min: 8, max: 60 },
|
|
168
|
+
default: 20,
|
|
169
|
+
description: "How many rows of archive to show at once, at most.",
|
|
170
|
+
});
|
|
171
|
+
await neosh.opt.declare({
|
|
172
|
+
name: "archive.sidebar",
|
|
173
|
+
type: { type: "bool" },
|
|
174
|
+
default: false,
|
|
175
|
+
description:
|
|
176
|
+
"Put a row in the project panel saying how many conversations are archived. Off: the archive is a popup you open with `^F`, and a permanent line about what you have finished with is a line that column cannot spare.",
|
|
177
|
+
});
|
|
178
|
+
await neosh.opt.declare({
|
|
179
|
+
name: "archive.auto_days",
|
|
180
|
+
type: { type: "int", min: 0, max: 3650 },
|
|
181
|
+
default: 0,
|
|
182
|
+
description:
|
|
183
|
+
"Archive a conversation nothing has happened in for this many days. `0` is off. Archiving keeps every message and `^F` puts one back, which is why this is allowed to happen on its own — deleting on a timer is not.",
|
|
184
|
+
});
|
|
185
|
+
await neosh.opt.declare({
|
|
186
|
+
name: "archive.retention_days",
|
|
187
|
+
type: { type: "int", min: 0, max: 3650 },
|
|
188
|
+
default: 0,
|
|
189
|
+
description:
|
|
190
|
+
"How old an archived conversation has to be before neosh will mention it. `0` is off. Nothing is ever deleted by this — it is what `archive.sweep` and the reminder count.",
|
|
191
|
+
});
|
|
192
|
+
await neosh.opt.declare({
|
|
193
|
+
name: "archive.remind",
|
|
194
|
+
type: { type: "bool" },
|
|
195
|
+
default: true,
|
|
196
|
+
description:
|
|
197
|
+
"Say so, once a day, when there are archived conversations older than `archive.retention_days`. Off leaves the number to the panel.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// What is in the archive
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** An archived conversation, and whether this workspace is actually holding it. */
|
|
206
|
+
interface Entry {
|
|
207
|
+
info: SessionInfo;
|
|
208
|
+
/**
|
|
209
|
+
* In the store, rather than only on disk.
|
|
210
|
+
*
|
|
211
|
+
* Drawn nowhere and used for nothing but the count in the header. Every verb works either way —
|
|
212
|
+
* the host brings a conversation in from its file the moment one names it — and a row that said
|
|
213
|
+
* which side of a cap it happened to fall on would be an implementation detail with a glyph.
|
|
214
|
+
*/
|
|
215
|
+
loaded: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Archived conversations this workspace has never loaded, as of the last full scan.
|
|
220
|
+
*
|
|
221
|
+
* The count in the sidebar follows every conversation change, and reading the *directory* on every
|
|
222
|
+
* one of them would be a parse of every file you own each time a turn ends. So the expensive half
|
|
223
|
+
* of the answer is remembered and the cheap half is asked again: the store is authoritative about
|
|
224
|
+
* everything it holds, and what it does not hold cannot change without this process being told.
|
|
225
|
+
*/
|
|
226
|
+
let cold = 0;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Everything archived, from both places it can be.
|
|
230
|
+
*
|
|
231
|
+
* The store is a window onto the sessions directory and not the whole of it: past `RESTORE_LIMIT`
|
|
232
|
+
* a conversation is a file this workspace has never read. `session.stored` is that directory. Where
|
|
233
|
+
* the two disagree the store wins, because it is the one with the unsaved half of today in it.
|
|
234
|
+
*/
|
|
235
|
+
async function collect(neosh: Neosh): Promise<Entry[]> {
|
|
236
|
+
const [live, disk] = await Promise.all([
|
|
237
|
+
neosh.session.list({ includeArchived: true }).catch(() => [] as SessionInfo[]),
|
|
238
|
+
neosh.session.stored().catch(() => [] as SessionInfo[]),
|
|
239
|
+
]);
|
|
240
|
+
const loaded = new Set(live.map((s) => s.id));
|
|
241
|
+
const byId = new Map<string, SessionInfo>();
|
|
242
|
+
for (const s of disk) byId.set(s.id, s);
|
|
243
|
+
for (const s of live) byId.set(s.id, s);
|
|
244
|
+
const all = [...byId.values()]
|
|
245
|
+
.filter((s) => s.archived)
|
|
246
|
+
.map((info) => ({ info, loaded: loaded.has(info.id) }));
|
|
247
|
+
cold = all.filter((e) => !e.loaded).length;
|
|
248
|
+
return all;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** How many are archived, without reading the disk. See {@link cold}. */
|
|
252
|
+
async function archivedCount(neosh: Neosh): Promise<number> {
|
|
253
|
+
const live = await neosh.session
|
|
254
|
+
.list({ includeArchived: true })
|
|
255
|
+
.catch(() => [] as SessionInfo[]);
|
|
256
|
+
return live.filter((s) => s.archived).length + cold;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Seconds since the epoch, the unit every timestamp on the wire is in. */
|
|
260
|
+
function now(): number {
|
|
261
|
+
return Math.floor(Date.now() / 1000);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** When a conversation was put away, falling back to when it was last touched. */
|
|
265
|
+
function putAway(s: SessionInfo): number {
|
|
266
|
+
return s.archived_at ?? s.updated_at;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function matches(e: Entry, filter: string): boolean {
|
|
270
|
+
if (filter === "") return true;
|
|
271
|
+
const hay = `${e.info.label} ${e.info.project} ${e.info.cwd}`.toLowerCase();
|
|
272
|
+
// Every word, in any order: `neosh login` finds the login conversation in the neosh checkout
|
|
273
|
+
// without caring which of the two you remembered first.
|
|
274
|
+
return filter.toLowerCase().split(/\s+/).filter((w) => w !== "").every((w) => hay.includes(w));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sorted(entries: Entry[], by: Sort): Entry[] {
|
|
278
|
+
const out = [...entries];
|
|
279
|
+
switch (by) {
|
|
280
|
+
case "used":
|
|
281
|
+
out.sort((a, b) => b.info.updated_at - a.info.updated_at);
|
|
282
|
+
break;
|
|
283
|
+
case "name":
|
|
284
|
+
out.sort((a, b) => a.info.label.localeCompare(b.info.label));
|
|
285
|
+
break;
|
|
286
|
+
case "project":
|
|
287
|
+
out.sort((a, b) =>
|
|
288
|
+
projectOf(a.info).localeCompare(projectOf(b.info)) || putAway(b.info) - putAway(a.info)
|
|
289
|
+
);
|
|
290
|
+
break;
|
|
291
|
+
case "size":
|
|
292
|
+
out.sort((a, b) => b.info.message_count - a.info.message_count);
|
|
293
|
+
break;
|
|
294
|
+
// Most recently put away first: the one you want back is usually the one you last regretted.
|
|
295
|
+
default:
|
|
296
|
+
out.sort((a, b) => putAway(b.info) - putAway(a.info));
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function projectOf(s: SessionInfo): string {
|
|
302
|
+
return s.project || basename(s.cwd) || s.cwd;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Which heading a conversation goes under.
|
|
307
|
+
*
|
|
308
|
+
* The age buckets are deliberately coarse. The question a heading answers here is "is this from
|
|
309
|
+
* this week or from another era", and a heading per day turns a list of forty into a list of
|
|
310
|
+
* seventy.
|
|
311
|
+
*/
|
|
312
|
+
function headingOf(e: Entry, group: Group, at: number): string | null {
|
|
313
|
+
if (group === "none") return null;
|
|
314
|
+
if (group === "project") return projectOf(e.info);
|
|
315
|
+
const days = (at - putAway(e.info)) / 86400;
|
|
316
|
+
if (days < 1) return "Today";
|
|
317
|
+
if (days < 7) return "This week";
|
|
318
|
+
if (days < 31) return "This month";
|
|
319
|
+
if (days < 366) return "This year";
|
|
320
|
+
return "Older";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The rows under their headings, each heading appearing exactly once.
|
|
325
|
+
*
|
|
326
|
+
* The order of the *groups* is the order the sort put their first member in, which is what makes
|
|
327
|
+
* grouping and sorting compose instead of fighting: ordered by when you put things away and grouped
|
|
328
|
+
* by project, the project you last finished something in is at the top, and each project's own rows
|
|
329
|
+
* are still newest first. Doing it a row at a time — start a new heading whenever this row's differs
|
|
330
|
+
* from the last one's — is the version that looks right on a sorted-by-project list and prints
|
|
331
|
+
* `neosh`, `website`, `neosh`, `scratch`, `neosh` on every other one.
|
|
332
|
+
*/
|
|
333
|
+
function grouped(
|
|
334
|
+
shown: Entry[],
|
|
335
|
+
group: Group,
|
|
336
|
+
at: number,
|
|
337
|
+
): Array<{ heading: string | null; entries: Entry[] }> {
|
|
338
|
+
if (group === "none") return [{ heading: null, entries: shown }];
|
|
339
|
+
const order: Array<{ heading: string; entries: Entry[] }> = [];
|
|
340
|
+
const index = new Map<string, { heading: string; entries: Entry[] }>();
|
|
341
|
+
for (const e of shown) {
|
|
342
|
+
const heading = headingOf(e, group, at) ?? "";
|
|
343
|
+
let bucket = index.get(heading);
|
|
344
|
+
if (bucket === undefined) {
|
|
345
|
+
bucket = { heading, entries: [] };
|
|
346
|
+
index.set(heading, bucket);
|
|
347
|
+
order.push(bucket);
|
|
348
|
+
}
|
|
349
|
+
bucket.entries.push(e);
|
|
350
|
+
}
|
|
351
|
+
return order;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// The panel
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The window, the rows in it, and what is ticked.
|
|
360
|
+
*
|
|
361
|
+
* A class rather than a closure over ten `let`s because every command registered below needs to
|
|
362
|
+
* reach the same one, and a panel that is opened and closed as often as `^F` is pressed is a thing
|
|
363
|
+
* whose lifetime is worth naming.
|
|
364
|
+
*/
|
|
365
|
+
class Panel {
|
|
366
|
+
private win: WindowId | null = null;
|
|
367
|
+
private buf: BufferId | null = null;
|
|
368
|
+
private list: CursoredList<Target> | null = null;
|
|
369
|
+
private entries: Entry[] = [];
|
|
370
|
+
private shown: Entry[] = [];
|
|
371
|
+
/** Ticked conversations, by id. Kept across a refresh; dropped when the panel closes. */
|
|
372
|
+
readonly marks = new Set<string>();
|
|
373
|
+
filter = "";
|
|
374
|
+
/** Typed before a motion and consumed by it. On screen while it is half typed. */
|
|
375
|
+
readonly count = { pending: "" };
|
|
376
|
+
private width = 92;
|
|
377
|
+
private height = 20;
|
|
378
|
+
private ascii = false;
|
|
379
|
+
private drawing = false;
|
|
380
|
+
private again = false;
|
|
381
|
+
private actions: ActionItem[] = [];
|
|
382
|
+
|
|
383
|
+
constructor(private readonly neosh: Neosh) {}
|
|
384
|
+
|
|
385
|
+
isOpen(): boolean {
|
|
386
|
+
return this.win !== null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The row under the cursor, whether or not it is ticked. */
|
|
390
|
+
cursorEntry(): Entry | null {
|
|
391
|
+
const id = this.list?.value?.id;
|
|
392
|
+
return this.shown.find((e) => e.info.id === id) ?? null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* What a verb is about: everything ticked, or the row under the cursor.
|
|
397
|
+
*
|
|
398
|
+
* One rule for every verb in the panel, including contributed ones. The alternative — some keys
|
|
399
|
+
* take the marks and some take the cursor — is a set of keys you have to remember the arity of,
|
|
400
|
+
* which is the thing marks were supposed to remove.
|
|
401
|
+
*/
|
|
402
|
+
selection(): Entry[] {
|
|
403
|
+
if (this.marks.size > 0) {
|
|
404
|
+
const marked = this.shown.filter((e) => this.marks.has(e.info.id));
|
|
405
|
+
// Ticked rows the filter is currently hiding still count: you ticked them, and a filter is a
|
|
406
|
+
// way of finding things rather than a way of un-ticking them.
|
|
407
|
+
const hidden = this.entries.filter(
|
|
408
|
+
(e) => this.marks.has(e.info.id) && !this.shown.includes(e),
|
|
409
|
+
);
|
|
410
|
+
return [...marked, ...hidden];
|
|
411
|
+
}
|
|
412
|
+
const one = this.cursorEntry();
|
|
413
|
+
return one ? [one] : [];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Everything the panel is currently listing — what `^X` means by "the archive". */
|
|
417
|
+
listed(): Entry[] {
|
|
418
|
+
return [...this.shown];
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
filtered(): boolean {
|
|
422
|
+
return this.filter !== "";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* How many rows the panel needs: its chrome, its headings, its rows, and one to unfold into.
|
|
427
|
+
*
|
|
428
|
+
* The last one is the row under the cursor becoming two lines when its title did not fit, which
|
|
429
|
+
* is a line the panel has to have somewhere to put or every long title scrolls the header away.
|
|
430
|
+
*/
|
|
431
|
+
private fits(group: Group): number {
|
|
432
|
+
const at = now();
|
|
433
|
+
const headings = group === "none"
|
|
434
|
+
? 0
|
|
435
|
+
: new Set(this.entries.map((e) => headingOf(e, group, at))).size;
|
|
436
|
+
// header, blank, headings, rows, blank, two lines of keys, one to unfold into.
|
|
437
|
+
const needed = 2 + headings + this.entries.length + 3 + 1;
|
|
438
|
+
return Math.max(10, Math.min(this.height + 8, needed));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async open(): Promise<void> {
|
|
442
|
+
if (this.win !== null) {
|
|
443
|
+
await this.close();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const [width, height, ascii] = await Promise.all([
|
|
447
|
+
this.neosh.opt.get<number>("archive.width"),
|
|
448
|
+
this.neosh.opt.get<number>("archive.height"),
|
|
449
|
+
this.neosh.opt.get<boolean>("ui.ascii_only"),
|
|
450
|
+
]);
|
|
451
|
+
this.width = width ?? 92;
|
|
452
|
+
this.height = height ?? 20;
|
|
453
|
+
this.ascii = ascii ?? false;
|
|
454
|
+
|
|
455
|
+
const group = (await this.neosh.opt.get<Group>("archive.group")) ?? "project";
|
|
456
|
+
this.entries = await collect(this.neosh);
|
|
457
|
+
if (this.entries.length === 0) {
|
|
458
|
+
this.neosh.notify("nothing is archived — `x` on a conversation puts it here", "info");
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const buf = await this.neosh.buf.create({ name: "[archive]", scratch: true, kind: KIND });
|
|
463
|
+
const ns = await this.neosh.ns.create(NS);
|
|
464
|
+
this.buf = buf;
|
|
465
|
+
// The width the list measures continuation lines against is the *content* width: an extent
|
|
466
|
+
// measures content and the frontend draws the border around it, so nothing here subtracts two.
|
|
467
|
+
this.list = new CursoredList<Target>(this.neosh, buf, ns, { width: () => this.width });
|
|
468
|
+
|
|
469
|
+
const win = await this.neosh.float.open(buf, {
|
|
470
|
+
anchor: { kind: "screen" },
|
|
471
|
+
// Fixed rather than `max`, which measures the *content*: a panel whose width was the length
|
|
472
|
+
// of its longest title would be a different width every time you filtered it, and the column
|
|
473
|
+
// its right-hand counts line up in would move with it.
|
|
474
|
+
width: { kind: "fixed", n: this.width },
|
|
475
|
+
// Sized to what there is to show, and never taller than `archive.height` allows.
|
|
476
|
+
//
|
|
477
|
+
// A float cannot be resized without being reopened, and reopening gives the window a new id
|
|
478
|
+
// and drops the keyboard — so the height is decided once, here, from the rows that exist.
|
|
479
|
+
// Asking for more than the content needs is not free: the foot is pinned to the bottom edge,
|
|
480
|
+
// so the buffer is padded out to the window's height, and a buffer one line longer than the
|
|
481
|
+
// window it is drawn in scrolls — which on a panel means the header quietly leaving the top
|
|
482
|
+
// of it on the first keypress. Filtering only ever shortens the list, and `pinned` is what
|
|
483
|
+
// holds the strip down when it does.
|
|
484
|
+
height: { kind: "max", n: this.fits(group) },
|
|
485
|
+
border: "rounded",
|
|
486
|
+
title: " Archived ",
|
|
487
|
+
focusable: true,
|
|
488
|
+
// Not on blur: a confirmation opens over this and takes the keyboard, and a panel that shut
|
|
489
|
+
// itself the moment it was asked a question would answer it into an empty screen.
|
|
490
|
+
closeOnBlur: false,
|
|
491
|
+
// Nothing else reaches the keyboard while this is up. It is a panel you are in the middle of
|
|
492
|
+
// using, and `^N` over it opening a conversation behind it is exactly what modality exists to stop.
|
|
493
|
+
// `^Q` and `^R` still resolve — see `ui.modal_escape_keys` — so this can never be a terminal
|
|
494
|
+
// somebody has to kill, and `^F` closes it from inside because a modal that borrows a global
|
|
495
|
+
// key to open itself owes a binding to shut itself.
|
|
496
|
+
modal: true,
|
|
497
|
+
z: 200,
|
|
498
|
+
});
|
|
499
|
+
this.win = win;
|
|
500
|
+
await this.neosh.focus.push(win);
|
|
501
|
+
this.actions = (await this.neosh.ext.list<ActionItem>(POINT_ACTION).catch(() => []))
|
|
502
|
+
.map((c) => c.item)
|
|
503
|
+
.filter((a): a is ActionItem => !!a?.key && !!a.command);
|
|
504
|
+
await this.draw();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async close(): Promise<void> {
|
|
508
|
+
const win = this.win;
|
|
509
|
+
if (win === null) return;
|
|
510
|
+
this.win = null;
|
|
511
|
+
this.marks.clear();
|
|
512
|
+
this.filter = "";
|
|
513
|
+
this.count.pending = "";
|
|
514
|
+
await this.neosh.focus.pop().catch(() => {});
|
|
515
|
+
await this.neosh.win.close(win).catch(() => {});
|
|
516
|
+
this.buf = null;
|
|
517
|
+
this.list = null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Re-read what is archived. Called after anything that changed it. */
|
|
521
|
+
async refresh(): Promise<void> {
|
|
522
|
+
this.entries = await collect(this.neosh);
|
|
523
|
+
// A tick on a conversation that is no longer archived is a tick on nothing, and leaving it
|
|
524
|
+
// would mean the next verb quietly acted on a row that is not on screen.
|
|
525
|
+
const live = new Set(this.entries.map((e) => e.info.id));
|
|
526
|
+
for (const id of [...this.marks]) if (!live.has(id)) this.marks.delete(id);
|
|
527
|
+
await refreshSidebarRow(this.neosh);
|
|
528
|
+
if (this.entries.length === 0) {
|
|
529
|
+
await this.close();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
await this.draw();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** How many rows of panel there are, for the two keys that mean "a screen". */
|
|
536
|
+
async rows(): Promise<number> {
|
|
537
|
+
if (this.win === null) return this.height;
|
|
538
|
+
const view = await this.neosh.win.viewport(this.win).catch(() => null);
|
|
539
|
+
// Less the header and the pinned foot, so `^D` is half of the list rather than half of the box.
|
|
540
|
+
return Math.max(2, (view?.height ?? this.height) - 4);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
move(delta: number, opts: { wrap?: boolean } = {}): void {
|
|
544
|
+
this.list?.move(delta, opts);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
toEnd(which: "first" | "last"): void {
|
|
548
|
+
this.list?.toEnd(which);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
nth(n: number): void {
|
|
552
|
+
this.list?.nth(n);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async draw(): Promise<void> {
|
|
556
|
+
if (this.win === null || this.list === null) return;
|
|
557
|
+
if (this.drawing) {
|
|
558
|
+
this.again = true;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
this.drawing = true;
|
|
562
|
+
try {
|
|
563
|
+
do {
|
|
564
|
+
this.again = false;
|
|
565
|
+
const [sort, group, view] = await Promise.all([
|
|
566
|
+
this.neosh.opt.get<Sort>("archive.sort"),
|
|
567
|
+
this.neosh.opt.get<Group>("archive.group"),
|
|
568
|
+
this.neosh.win.viewport(this.win).catch(() => null),
|
|
569
|
+
]);
|
|
570
|
+
// What the panel actually got, which on a terminal narrower than `archive.width` is not
|
|
571
|
+
// what it asked for. Clipping to the number we wanted rather than the number we have is how
|
|
572
|
+
// a title runs into the border on a small window.
|
|
573
|
+
if (view && view.width > 0) this.width = view.width;
|
|
574
|
+
const at = now();
|
|
575
|
+
this.shown = sorted(this.entries.filter((e) => matches(e, this.filter)), sort ?? "archived");
|
|
576
|
+
const built = this.build(this.shown, group ?? "project", at, sort ?? "archived");
|
|
577
|
+
this.list.setRows(built, (a, b) => a.id === b.id);
|
|
578
|
+
await this.list.render({ showCursor: true, win: this.win, pinned: 3 });
|
|
579
|
+
} while (this.again);
|
|
580
|
+
} finally {
|
|
581
|
+
this.drawing = false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The header, the headings, the conversations, and the keys. */
|
|
586
|
+
private build(shown: Entry[], group: Group, at: number, sort: Sort): ListRow<Target>[] {
|
|
587
|
+
const rows: ListRow<Target>[] = [];
|
|
588
|
+
const glyph = this.ascii
|
|
589
|
+
? { tick: "*", blank: " ", rule: "-" }
|
|
590
|
+
: { tick: "✓", blank: " ", rule: "┈" };
|
|
591
|
+
|
|
592
|
+
rows.push(this.header(shown, sort, group));
|
|
593
|
+
rows.push({ text: "", inert: true });
|
|
594
|
+
|
|
595
|
+
for (const bucket of grouped(shown, group, at)) {
|
|
596
|
+
if (bucket.heading !== null) {
|
|
597
|
+
rows.push({
|
|
598
|
+
text: ` ${clip(bucket.heading, this.width - 3)}`,
|
|
599
|
+
hl: "Sidebar.Heading",
|
|
600
|
+
inert: true,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
for (const e of bucket.entries) rows.push(this.row(e, at, glyph));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (shown.length === 0) {
|
|
607
|
+
rows.push({
|
|
608
|
+
text: ` nothing archived matches "${clip(this.filter, 40)}"`,
|
|
609
|
+
hl: "Sidebar.Dim",
|
|
610
|
+
inert: true,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
rows.push({ text: "", inert: true });
|
|
615
|
+
for (const line of this.hints()) {
|
|
616
|
+
rows.push({ text: ` ${clip(line, this.width - 2)}`, hl: "Sidebar.Dim", inert: true });
|
|
617
|
+
}
|
|
618
|
+
return rows;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private header(shown: Entry[], sort: Sort, group: Group): ListRow<Target> {
|
|
622
|
+
const total = this.entries.length;
|
|
623
|
+
const parts = [`${total} archived`];
|
|
624
|
+
if (this.filter !== "") parts.push(`${shown.length} matching "${clip(this.filter, 24)}"`);
|
|
625
|
+
if (this.marks.size > 0) parts.push(`${this.marks.size} marked`);
|
|
626
|
+
// Said once, and only when it is true: past the restore cap a conversation is a file this
|
|
627
|
+
// workspace has never read, and a count that quietly differed from the sidebar's would be the
|
|
628
|
+
// kind of discrepancy people spend an afternoon on.
|
|
629
|
+
const cold = this.entries.filter((e) => !e.loaded).length;
|
|
630
|
+
if (cold > 0) parts.push(`${cold} on disk only`);
|
|
631
|
+
const right = this.count.pending !== ""
|
|
632
|
+
? `${this.count.pending}`
|
|
633
|
+
: `${SORT_LABEL[sort]} · ${GROUP_LABEL[group]} `;
|
|
634
|
+
return {
|
|
635
|
+
// Measured against what is on the right rather than a number that was true when it was
|
|
636
|
+
// written: the right-hand text is drawn as virtual text and pushes nothing, so a header
|
|
637
|
+
// clipped to a guess is a header that runs underneath it.
|
|
638
|
+
text: ` ${clip(parts.join(" · "), Math.max(12, this.width - right.length - 3))}`,
|
|
639
|
+
hl: "Sidebar.Heading",
|
|
640
|
+
right: { text: right, hl: "Sidebar.Dim" },
|
|
641
|
+
inert: true,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private row(
|
|
646
|
+
e: Entry,
|
|
647
|
+
at: number,
|
|
648
|
+
glyph: { tick: string; blank: string; rule: string },
|
|
649
|
+
): ListRow<Target> {
|
|
650
|
+
const marked = this.marks.has(e.info.id);
|
|
651
|
+
const count = e.info.message_count;
|
|
652
|
+
const when = ago(at - putAway(e.info));
|
|
653
|
+
const right = `${count} ${count === 1 ? "msg" : "msgs"}${when === "" ? "" : ` · ${when}`} `;
|
|
654
|
+
const prefix = ` ${marked ? glyph.tick : glyph.blank} `;
|
|
655
|
+
// The room a label has is what is left after the marker and the right-hand column, which is
|
|
656
|
+
// drawn as virtual text and therefore does not push anything: measured, not guessed.
|
|
657
|
+
const room = Math.max(8, this.width - byteLength(prefix) - right.length - 2);
|
|
658
|
+
const full = `${prefix}${e.info.label}`;
|
|
659
|
+
return {
|
|
660
|
+
text: `${prefix}${clip(e.info.label, room)}`,
|
|
661
|
+
// The rest of a clipped title, while the cursor is on it. A list of conversations whose
|
|
662
|
+
// titles all end in `…` is a list you cannot tell two rows of apart.
|
|
663
|
+
full,
|
|
664
|
+
indent: byteLength(prefix),
|
|
665
|
+
right: { text: right, hl: "Sidebar.Dim" },
|
|
666
|
+
spans: marked
|
|
667
|
+
? [{ from: 1, to: 1 + byteLength(glyph.tick), hl: "Status.Ok" }]
|
|
668
|
+
: undefined,
|
|
669
|
+
value: { kind: "session", id: e.info.id },
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The keys, at the foot, changing with what is ticked.
|
|
675
|
+
*
|
|
676
|
+
* Written out rather than inferred from the keymap because these are the panel's own bindings and
|
|
677
|
+
* it knows them — but the *verbs* on the second row are read off the contributions, so a plugin
|
|
678
|
+
* that adds a key to this panel gets it advertised rather than having to document it elsewhere.
|
|
679
|
+
*/
|
|
680
|
+
private hints(): string[] {
|
|
681
|
+
const many = this.marks.size > 0;
|
|
682
|
+
const what = many ? `${this.marks.size} marked` : "this one";
|
|
683
|
+
const contributed = this.actions.map((a) => `${a.key} ${a.label}`).join(" ");
|
|
684
|
+
return [
|
|
685
|
+
`↵ open u put back (${what}) X delete (${what}) space mark a all`,
|
|
686
|
+
`/ filter s sort S group e copy ^X empty${
|
|
687
|
+
this.filtered() ? " what is shown" : ""
|
|
688
|
+
} esc close${contributed === "" ? "" : ` ${contributed}`}`,
|
|
689
|
+
];
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
// Verbs
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async function registerCommands(
|
|
698
|
+
neosh: Neosh,
|
|
699
|
+
subscriptions: Disposable[],
|
|
700
|
+
panel: Panel,
|
|
701
|
+
): Promise<void> {
|
|
702
|
+
const keep = async (name: string, desc: string, fn: () => Promise<void> | void) => {
|
|
703
|
+
subscriptions.push(await neosh.cmd.register(name, () => fn(), { desc }));
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
await keep("archive.open", "What you have archived", () => panel.open());
|
|
707
|
+
// The name the sidebar used to answer to, kept because anybody's `init.ts` may be pointed at it.
|
|
708
|
+
await keep("session.archived", "Browse what you have archived", () => panel.open());
|
|
709
|
+
await keep(
|
|
710
|
+
"archive.sweep",
|
|
711
|
+
"Delete everything archived longer ago than `archive.retention_days`",
|
|
712
|
+
() => sweep(neosh),
|
|
713
|
+
);
|
|
714
|
+
await keep(
|
|
715
|
+
"archive.tidy",
|
|
716
|
+
"Archive conversations idle longer than `archive.auto_days`",
|
|
717
|
+
async () => {
|
|
718
|
+
await autoArchive(neosh, true);
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Register one verb, and bind it inside this panel.
|
|
725
|
+
*
|
|
726
|
+
* Every key here goes through this, which is the point: `^Z` lists them, `^K` runs them, and
|
|
727
|
+
* `keymap.set("chat", "d", "…", { scope: { kind: "buf_kind", name: "neosh.archive" } })` from
|
|
728
|
+
* anybody's `init.ts` replaces one. The scope is the buffer *kind* rather than the window, because
|
|
729
|
+
* the window is made and destroyed every time the panel is opened and a binding on it would go with
|
|
730
|
+
* it.
|
|
731
|
+
*/
|
|
732
|
+
async function bindKeys(
|
|
733
|
+
neosh: Neosh,
|
|
734
|
+
subscriptions: Disposable[],
|
|
735
|
+
panel: Panel,
|
|
736
|
+
): Promise<void> {
|
|
737
|
+
const scope = { kind: "buf_kind", name: KIND } as const;
|
|
738
|
+
|
|
739
|
+
const verb = async (
|
|
740
|
+
name: string,
|
|
741
|
+
keys: string | string[] | null,
|
|
742
|
+
desc: string,
|
|
743
|
+
fn: () => Promise<void> | void,
|
|
744
|
+
opts: { redraw?: boolean } = {},
|
|
745
|
+
): Promise<void> => {
|
|
746
|
+
subscriptions.push(await neosh.cmd.register(name, async () => {
|
|
747
|
+
await fn();
|
|
748
|
+
// A count belongs to the motion typed straight after it. Anything else ends it — otherwise a
|
|
749
|
+
// `5` you thought better of sits there and turns the next `j` into five.
|
|
750
|
+
panel.count.pending = "";
|
|
751
|
+
if (opts.redraw !== false) await panel.draw();
|
|
752
|
+
}, { desc }));
|
|
753
|
+
for (const key of keys === null ? [] : typeof keys === "string" ? [keys] : keys) {
|
|
754
|
+
await neosh.keymap.set("chat", key, name, { scope, desc });
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
/** Take the count that was typed, if one was, and forget it. */
|
|
759
|
+
const take = (fallback = 1): number => {
|
|
760
|
+
const n = Number.parseInt(panel.count.pending, 10);
|
|
761
|
+
panel.count.pending = "";
|
|
762
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 999) : fallback;
|
|
763
|
+
};
|
|
764
|
+
const typed = (): number | null => {
|
|
765
|
+
const n = Number.parseInt(panel.count.pending, 10);
|
|
766
|
+
panel.count.pending = "";
|
|
767
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// ---- moving ----
|
|
771
|
+
await verb(`${NS}.down`, ["j", "<Down>", "<C-n>"], "Next row", () => panel.move(take()));
|
|
772
|
+
await verb(`${NS}.up`, ["k", "<Up>", "<C-p>"], "Previous row", () => panel.move(-take()));
|
|
773
|
+
|
|
774
|
+
const by = (fraction: number, sign: 1 | -1) => async (): Promise<void> => {
|
|
775
|
+
const rows = await panel.rows();
|
|
776
|
+
const step = Math.max(1, Math.floor(rows * fraction)) * take();
|
|
777
|
+
// No wrapping on a page step: `^D` at the foot means there is no more of it, and a cursor that
|
|
778
|
+
// reappears at the top has thrown away the place you were reading from.
|
|
779
|
+
panel.move(sign * step, { wrap: false });
|
|
780
|
+
};
|
|
781
|
+
await verb(`${NS}.half.down`, "<C-d>", "Half a screen down", by(0.5, 1));
|
|
782
|
+
await verb(`${NS}.half.up`, "<C-u>", "Half a screen up", by(0.5, -1));
|
|
783
|
+
await verb(`${NS}.page.down`, "<PageDown>", "A screen down", by(1, 1));
|
|
784
|
+
await verb(`${NS}.page.up`, "<PageUp>", "A screen up", by(1, -1));
|
|
785
|
+
|
|
786
|
+
await verb(`${NS}.top`, "gg", "The first row, or the n-th with a count", () => {
|
|
787
|
+
const n = typed();
|
|
788
|
+
if (n === null) panel.toEnd("first");
|
|
789
|
+
else panel.nth(n);
|
|
790
|
+
});
|
|
791
|
+
await verb(`${NS}.bottom`, "G", "The last row, or the n-th with a count", () => {
|
|
792
|
+
const n = typed();
|
|
793
|
+
if (n === null) panel.toEnd("last");
|
|
794
|
+
else panel.nth(n);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// One command for all ten digits, reading which it was off the key that ran it — so `^Z` lists it
|
|
798
|
+
// once and `init.ts` can move it, rather than ten near-identical verbs.
|
|
799
|
+
subscriptions.push(await neosh.cmd.register(`${NS}.count`, async (_args, key) => {
|
|
800
|
+
const code = key?.key.code;
|
|
801
|
+
if (code?.kind !== "char") return;
|
|
802
|
+
if (panel.count.pending === "" && code.c === "0") return;
|
|
803
|
+
if (panel.count.pending.length < 3) panel.count.pending += code.c;
|
|
804
|
+
await panel.draw();
|
|
805
|
+
}, { desc: "Begin a count for the next motion" }));
|
|
806
|
+
for (const digit of "0123456789") {
|
|
807
|
+
await neosh.keymap.set("chat", digit, `${NS}.count`, {
|
|
808
|
+
scope,
|
|
809
|
+
desc: "Count for the next motion",
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---- marking ----
|
|
814
|
+
await verb(`${NS}.mark`, ["<Space>", "<Tab>"], "Tick or untick this conversation", () => {
|
|
815
|
+
// The row under the cursor, never the selection: ticking is about one row, and a `<Space>` that
|
|
816
|
+
// toggled the first of ten already-marked rows would be a key that undid somebody else's work.
|
|
817
|
+
const id = panel.cursorEntry()?.info.id ?? null;
|
|
818
|
+
if (id === null) return;
|
|
819
|
+
if (panel.marks.has(id)) panel.marks.delete(id);
|
|
820
|
+
else panel.marks.add(id);
|
|
821
|
+
// Down a row afterwards, so ticking a run of them is one key repeated rather than two
|
|
822
|
+
// alternating. `<Space>` at the foot of the list stays there rather than wrapping to the top.
|
|
823
|
+
panel.move(1, { wrap: false });
|
|
824
|
+
});
|
|
825
|
+
await verb(`${NS}.mark.all`, "a", "Tick everything showing, or untick it", () => {
|
|
826
|
+
const shown = panel.listed();
|
|
827
|
+
const all = shown.length > 0 && shown.every((e) => panel.marks.has(e.info.id));
|
|
828
|
+
if (all) for (const e of shown) panel.marks.delete(e.info.id);
|
|
829
|
+
else for (const e of shown) panel.marks.add(e.info.id);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// ---- what to do with them ----
|
|
833
|
+
await verb(`${NS}.open`, "<CR>", "Put this back and go to it", async () => {
|
|
834
|
+
// `↵` restores *and* opens, because opening something you put away is a statement that you want
|
|
835
|
+
// it back: leaving it archived while you work in it would mean the list you look at does not
|
|
836
|
+
// contain the conversation you are in.
|
|
837
|
+
const one = panel.cursorEntry();
|
|
838
|
+
if (!one) return;
|
|
839
|
+
try {
|
|
840
|
+
await neosh.session.archive(one.info.id, false);
|
|
841
|
+
await neosh.session.switch(one.info.id);
|
|
842
|
+
await panel.close();
|
|
843
|
+
await refreshSidebarRow(neosh);
|
|
844
|
+
neosh.notify(`restored "${clip(one.info.label, 40)}"`);
|
|
845
|
+
} catch (e) {
|
|
846
|
+
neosh.notify(String(e), "warn");
|
|
847
|
+
}
|
|
848
|
+
}, { redraw: false });
|
|
849
|
+
|
|
850
|
+
// `u`, and deliberately not `^U` as well. The picker had `^U` for this because a bare
|
|
851
|
+
// letter there is a letter the filter can never contain; in a panel whose filter is a prompt the
|
|
852
|
+
// letter is free, and `^U` has a job — half a screen — that every list in the
|
|
853
|
+
// workspace gives it. Binding both would have quietly taken the motion away, in the one panel most likely
|
|
854
|
+
// to be longer than a screen.
|
|
855
|
+
await verb(`${NS}.restore`, "u", "Put these back, without going there", async () => {
|
|
856
|
+
// Tidying and travelling are different intentions, and being thrown into a conversation per row
|
|
857
|
+
// you restore is not tidying.
|
|
858
|
+
const chosen = panel.selection();
|
|
859
|
+
if (chosen.length === 0) return;
|
|
860
|
+
let done = 0;
|
|
861
|
+
for (const e of chosen) {
|
|
862
|
+
try {
|
|
863
|
+
await neosh.session.archive(e.info.id, false);
|
|
864
|
+
done += 1;
|
|
865
|
+
} catch (err) {
|
|
866
|
+
neosh.notify(String(err), "warn");
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
panel.marks.clear();
|
|
871
|
+
if (done > 0) {
|
|
872
|
+
neosh.notify(done === 1 ? "put back" : `put ${done} conversations back`);
|
|
873
|
+
}
|
|
874
|
+
await panel.refresh();
|
|
875
|
+
}, { redraw: false });
|
|
876
|
+
|
|
877
|
+
await verb(`${NS}.delete`, "X", "Delete these, for good", async () => {
|
|
878
|
+
const chosen = panel.selection();
|
|
879
|
+
if (chosen.length === 0) return;
|
|
880
|
+
if (!(await confirmDelete(neosh, chosen, "delete"))) return;
|
|
881
|
+
await destroy(neosh, chosen);
|
|
882
|
+
panel.marks.clear();
|
|
883
|
+
await panel.refresh();
|
|
884
|
+
}, { redraw: false });
|
|
885
|
+
|
|
886
|
+
await verb(`${NS}.empty`, "<C-x>", "Empty the archive", async () => {
|
|
887
|
+
const chosen = panel.listed();
|
|
888
|
+
if (chosen.length === 0) return;
|
|
889
|
+
if (!(await confirmDelete(neosh, chosen, panel.filtered() ? "empty-filtered" : "empty"))) return;
|
|
890
|
+
await destroy(neosh, chosen);
|
|
891
|
+
panel.marks.clear();
|
|
892
|
+
await panel.refresh();
|
|
893
|
+
}, { redraw: false });
|
|
894
|
+
|
|
895
|
+
await verb(`${NS}.copy`, "e", "Copy these conversations as markdown", async () => {
|
|
896
|
+
const chosen = panel.selection();
|
|
897
|
+
if (chosen.length === 0) return;
|
|
898
|
+
await copyTranscripts(neosh, chosen);
|
|
899
|
+
}, { redraw: false });
|
|
900
|
+
|
|
901
|
+
await verb(`${NS}.copy.path`, "y", "Copy this conversation's directory", async () => {
|
|
902
|
+
const one = panel.cursorEntry();
|
|
903
|
+
if (!one) return;
|
|
904
|
+
await neosh.edit.copy(one.info.cwd);
|
|
905
|
+
neosh.notify(`copied ${one.info.cwd}`);
|
|
906
|
+
}, { redraw: false });
|
|
907
|
+
|
|
908
|
+
// ---- how the list is shown ----
|
|
909
|
+
//
|
|
910
|
+
// A prompt rather than typing straight into the panel. Every letter here is a verb — `a`, `e`,
|
|
911
|
+
// `u`, `y` — and a filter that took them would be a filter that could not contain them; shadowing
|
|
912
|
+
// twenty-six bindings for the duration of a search is the kind of mode that goes wrong once and
|
|
913
|
+
// then goes wrong every time.
|
|
914
|
+
await verb(`${NS}.filter`, "/", "Filter the list", async () => {
|
|
915
|
+
const typedIn = await prompt(neosh, "Filter the archive", { initial: panel.filter, width: 56 });
|
|
916
|
+
if (typedIn === null) return;
|
|
917
|
+
panel.filter = typedIn.trim();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
await verb(`${NS}.sort`, "s", "The next ordering along", async () => {
|
|
921
|
+
const at = SORTS.indexOf((await neosh.opt.get<Sort>("archive.sort")) ?? "archived");
|
|
922
|
+
await neosh.opt.set("archive.sort", SORTS[(at + 1) % SORTS.length]!);
|
|
923
|
+
});
|
|
924
|
+
await verb(`${NS}.group`, "S", "The next grouping along", async () => {
|
|
925
|
+
const at = GROUPS.indexOf((await neosh.opt.get<Group>("archive.group")) ?? "project");
|
|
926
|
+
await neosh.opt.set("archive.group", GROUPS[(at + 1) % GROUPS.length]!);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
await verb(`${NS}.help`, "?", "The keys for this panel", async () => {
|
|
930
|
+
await neosh.cmd.exec("help.keys").catch(() => {});
|
|
931
|
+
}, { redraw: false });
|
|
932
|
+
|
|
933
|
+
// ---- leaving ----
|
|
934
|
+
//
|
|
935
|
+
// One thing per press, the way `<Esc>` works in the transcript: what you have ticked, then what
|
|
936
|
+
// you have filtered to, then the panel. A key that threw all three away at once would mean a
|
|
937
|
+
// mistyped filter cost you a selection you had spent a minute making.
|
|
938
|
+
await verb(`${NS}.escape`, "<Esc>", "Drop the marks, the filter, then close", async () => {
|
|
939
|
+
if (panel.marks.size > 0) {
|
|
940
|
+
panel.marks.clear();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (panel.filter !== "") {
|
|
944
|
+
panel.filter = "";
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
await panel.close();
|
|
948
|
+
});
|
|
949
|
+
await verb(`${NS}.close`, ["q", "<C-c>", "<C-f>"], "Close the archive", () => panel.close(), {
|
|
950
|
+
redraw: false,
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// Where the keys nothing claimed go: nowhere. Not a handler — a sink. The float is modal, so an
|
|
954
|
+
// unbound key is swallowed rather than reaching the composer; this is what stops one that a
|
|
955
|
+
// *nearer* scope would otherwise leak.
|
|
956
|
+
subscriptions.push(await neosh.cmd.register(`${NS}.key`, () => {}, {
|
|
957
|
+
desc: "Swallow an unbound key in the archive",
|
|
958
|
+
}));
|
|
959
|
+
|
|
960
|
+
// The way in, from anywhere. `^F` has always been the archive's key and it toggles, because a key
|
|
961
|
+
// that opens a modal and cannot shut it is a key you have to remember a second one for.
|
|
962
|
+
await neosh.keymap.set("chat", "<C-f>", "archive.open", {
|
|
963
|
+
desc: "Archived conversations",
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
await bindContributed(neosh, subscriptions, panel, scope);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Keys somebody else put on these rows.
|
|
971
|
+
*
|
|
972
|
+
* Bound here rather than by the contributor, so the command is invoked with the conversations it is
|
|
973
|
+
* about and a plugin never has to track this panel's cursor. Re-read when the contributions change,
|
|
974
|
+
* because a plugin that loads after this panel has drawn would otherwise have a key nobody sees
|
|
975
|
+
* until the next restart.
|
|
976
|
+
*/
|
|
977
|
+
async function bindContributed(
|
|
978
|
+
neosh: Neosh,
|
|
979
|
+
subscriptions: Disposable[],
|
|
980
|
+
panel: Panel,
|
|
981
|
+
scope: { kind: "buf_kind"; name: string },
|
|
982
|
+
): Promise<void> {
|
|
983
|
+
/** The keys and commands from the last pass, so a re-read replaces rather than accumulates. */
|
|
984
|
+
let bound: Array<{ key: string; cmd: Disposable }> = [];
|
|
985
|
+
|
|
986
|
+
const apply = async () => {
|
|
987
|
+
const items = (await neosh.ext.list<ActionItem>(POINT_ACTION).catch(() => []))
|
|
988
|
+
.map((c) => c.item)
|
|
989
|
+
.filter((a): a is ActionItem => !!a?.key && !!a.command);
|
|
990
|
+
for (const previous of bound) {
|
|
991
|
+
await neosh.keymap.del("chat", previous.key, scope).catch(() => {});
|
|
992
|
+
previous.cmd.dispose();
|
|
993
|
+
}
|
|
994
|
+
bound = [];
|
|
995
|
+
for (const action of items) {
|
|
996
|
+
const name = `${NS}.custom.${action.command}`;
|
|
997
|
+
const cmd = await neosh.cmd.register(name, async () => {
|
|
998
|
+
// The conversations it is about, as arguments. A contributed verb never has to find this
|
|
999
|
+
// panel's cursor, which is the whole reason the key is bound here rather than by the
|
|
1000
|
+
// plugin that wanted it.
|
|
1001
|
+
const ids = panel.selection().map((e) => e.info.id);
|
|
1002
|
+
await neosh.cmd.exec(action.command, ids).catch((e) => neosh.notify(String(e), "warn"));
|
|
1003
|
+
await panel.refresh();
|
|
1004
|
+
}, { desc: action.label });
|
|
1005
|
+
await neosh.keymap.set("chat", action.key, name, { scope, desc: action.label });
|
|
1006
|
+
bound.push({ key: action.key, cmd });
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
await apply();
|
|
1011
|
+
subscriptions.push(neosh.ext.onChange((e) => {
|
|
1012
|
+
if (e.point === POINT_ACTION) void apply();
|
|
1013
|
+
}));
|
|
1014
|
+
subscriptions.push({
|
|
1015
|
+
dispose: () => {
|
|
1016
|
+
for (const previous of bound) previous.cmd.dispose();
|
|
1017
|
+
},
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
// Deleting, and being asked about it
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* The question, with what is at stake in it.
|
|
1027
|
+
*
|
|
1028
|
+
* The rule: everything irreversible asks, unconditionally, and the value of the question is
|
|
1029
|
+
* that it is always there. So it says how many, how much is in them and where they came from —
|
|
1030
|
+
* "Are you sure?" is a speed bump you learn to clear without reading, and a number is not.
|
|
1031
|
+
*/
|
|
1032
|
+
async function confirmDelete(
|
|
1033
|
+
neosh: Neosh,
|
|
1034
|
+
chosen: Entry[],
|
|
1035
|
+
shape: "delete" | "empty" | "empty-filtered",
|
|
1036
|
+
): Promise<boolean> {
|
|
1037
|
+
const n = chosen.length;
|
|
1038
|
+
const messages = chosen.reduce((sum, e) => sum + e.info.message_count, 0);
|
|
1039
|
+
const projects = new Set(chosen.map((e) => projectOf(e.info))).size;
|
|
1040
|
+
const oldest = chosen.reduce((old, e) => Math.min(old, putAway(e.info)), Number.MAX_SAFE_INTEGER);
|
|
1041
|
+
const span = ago(now() - oldest);
|
|
1042
|
+
|
|
1043
|
+
const question = shape === "delete"
|
|
1044
|
+
? (n === 1
|
|
1045
|
+
? `Delete "${clip(chosen[0]!.info.label, 44)}"?`
|
|
1046
|
+
: `Delete ${n} archived conversations?`)
|
|
1047
|
+
: shape === "empty-filtered"
|
|
1048
|
+
? `Delete all ${n} archived conversations shown?`
|
|
1049
|
+
: `Empty the archive — all ${n} conversations?`;
|
|
1050
|
+
|
|
1051
|
+
const detail = [
|
|
1052
|
+
`${messages} ${messages === 1 ? "message" : "messages"} in ${
|
|
1053
|
+
n === 1 ? "it" : "them"
|
|
1054
|
+
}, from ${projects} ${projects === 1 ? "project" : "projects"}${
|
|
1055
|
+
span === "" ? "" : `, going back ${span}`
|
|
1056
|
+
}.`,
|
|
1057
|
+
n === 1
|
|
1058
|
+
? "It goes from disk, and there is no undo."
|
|
1059
|
+
: "They go from disk, and there is no undo.",
|
|
1060
|
+
"`u` puts one back into your list instead, and costs nothing.",
|
|
1061
|
+
];
|
|
1062
|
+
|
|
1063
|
+
return confirmDestructive(neosh, question, { yes: "Delete", no: "Keep", detail });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Delete them, saying what went and stopping at the first thing that would not. */
|
|
1067
|
+
async function destroy(neosh: Neosh, chosen: Entry[]): Promise<number> {
|
|
1068
|
+
let done = 0;
|
|
1069
|
+
for (const e of chosen) {
|
|
1070
|
+
try {
|
|
1071
|
+
await neosh.session.close(e.info.id);
|
|
1072
|
+
done += 1;
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
// Named, because "12 of 40" with no reason is a state nobody can act on. The commonest cause
|
|
1075
|
+
// is a conversation that stopped being archived while the panel was open.
|
|
1076
|
+
neosh.notify(`stopped after ${done}: ${String(err)}`, "warn");
|
|
1077
|
+
return done;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (done > 0) neosh.notify(done === 1 ? "deleted" : `deleted ${done} conversations`);
|
|
1081
|
+
return done;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ---------------------------------------------------------------------------
|
|
1085
|
+
// Getting a piece of it out
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Copy what was said, as markdown.
|
|
1090
|
+
*
|
|
1091
|
+
* The clipboard rather than a file, and the reason is the same one that makes `Clipboard` an OSC
|
|
1092
|
+
* escape: the workspace may be on another machine entirely, so a path it writes to is a path on the
|
|
1093
|
+
* wrong computer. This is the transcript reader's `ya` aimed at conversations you are about to
|
|
1094
|
+
* throw away — which is the last moment anybody would want it.
|
|
1095
|
+
*/
|
|
1096
|
+
async function copyTranscripts(neosh: Neosh, chosen: Entry[]): Promise<void> {
|
|
1097
|
+
const parts: string[] = [];
|
|
1098
|
+
for (const e of chosen) {
|
|
1099
|
+
const messages = await neosh.session.messages(e.info.id).catch(() => [] as Message[]);
|
|
1100
|
+
parts.push(render(e.info, messages));
|
|
1101
|
+
}
|
|
1102
|
+
const text = parts.join("\n\n---\n\n");
|
|
1103
|
+
if (text.trim() === "") {
|
|
1104
|
+
neosh.notify("nothing was said in that", "info");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
await neosh.edit.copy(text);
|
|
1108
|
+
const n = chosen.length;
|
|
1109
|
+
neosh.notify(n === 1 ? "copied the conversation" : `copied ${n} conversations`);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function render(info: SessionInfo, messages: Message[]): string {
|
|
1113
|
+
const out = [`# ${info.label}`, "", `_${info.project || info.cwd} · ${info.message_count} messages_`, ""];
|
|
1114
|
+
for (const m of messages) {
|
|
1115
|
+
const text = m.content
|
|
1116
|
+
.map((b) => {
|
|
1117
|
+
switch (b.type) {
|
|
1118
|
+
case "text":
|
|
1119
|
+
return b.text;
|
|
1120
|
+
// Left out on purpose: reasoning is the model's working, a tool result is often thousands
|
|
1121
|
+
// of lines of one, and what somebody wants out of a conversation is what was said in it.
|
|
1122
|
+
case "tool_use":
|
|
1123
|
+
return `> ran \`${b.name}\``;
|
|
1124
|
+
default:
|
|
1125
|
+
return "";
|
|
1126
|
+
}
|
|
1127
|
+
})
|
|
1128
|
+
.filter((t) => t !== "")
|
|
1129
|
+
.join("\n\n");
|
|
1130
|
+
if (text === "") continue;
|
|
1131
|
+
out.push(`## ${m.role === "user" ? "You" : m.role === "assistant" ? "Agent" : m.role}`, "", text, "");
|
|
1132
|
+
}
|
|
1133
|
+
return out.join("\n");
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
// Housekeeping
|
|
1138
|
+
// ---------------------------------------------------------------------------
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* What the workspace does about the archive on its own, which is: archive, and mention.
|
|
1142
|
+
*
|
|
1143
|
+
* `archive.auto_days` puts an idle conversation away. That is allowed to happen without being asked
|
|
1144
|
+
* because archiving is reversible and free — it is the *test* for whether a dialog is owed, applied
|
|
1145
|
+
* from the other side — and the alert says how many so it is never something you find out about by
|
|
1146
|
+
* noticing a row has gone.
|
|
1147
|
+
*
|
|
1148
|
+
* `archive.retention_days` deletes nothing, ever. It says how much of the archive is old and points
|
|
1149
|
+
* at the key that empties it. The workspace refuses to delete history on a timer, and a
|
|
1150
|
+
* setting that quietly reversed that would be the program deciding something that is not its to
|
|
1151
|
+
* decide. `archive.sweep` is the same number with a person behind it.
|
|
1152
|
+
*/
|
|
1153
|
+
async function housekeeping(neosh: Neosh): Promise<void> {
|
|
1154
|
+
// The reminder waits for a pass that did nothing else. A notice is a *reply* and does not stack,
|
|
1155
|
+
// so two sent a moment apart is one notice: `archived 2 conversations idle for 7+ days` arrived
|
|
1156
|
+
// and was immediately painted over by `1 is older than 30 days`, which is the less useful of the
|
|
1157
|
+
// two by a distance — one is something that just happened to your list, the other is a standing
|
|
1158
|
+
// fact that will still be true in six hours when this runs again.
|
|
1159
|
+
const moved = await autoArchive(neosh, false);
|
|
1160
|
+
if (moved === 0) await remind(neosh);
|
|
1161
|
+
await refreshSidebarRow(neosh);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async function autoArchive(neosh: Neosh, spoken: boolean): Promise<number> {
|
|
1165
|
+
const days = (await neosh.opt.get<number>("archive.auto_days").catch(() => 0)) ?? 0;
|
|
1166
|
+
if (days <= 0) {
|
|
1167
|
+
if (spoken) neosh.notify("`archive.auto_days` is off", "info");
|
|
1168
|
+
return 0;
|
|
1169
|
+
}
|
|
1170
|
+
const cutoff = now() - days * 86400;
|
|
1171
|
+
const sessions = await neosh.session.list().catch(() => [] as SessionInfo[]);
|
|
1172
|
+
const stale = sessions.filter(
|
|
1173
|
+
(s) =>
|
|
1174
|
+
!s.archived &&
|
|
1175
|
+
!s.is_active &&
|
|
1176
|
+
// Never one that is working, and never one that is waiting on you: idleness is measured in
|
|
1177
|
+
// when something last happened, and a turn in flight is something happening now.
|
|
1178
|
+
!s.active_turn &&
|
|
1179
|
+
!s.unread &&
|
|
1180
|
+
s.message_count > 0 &&
|
|
1181
|
+
s.updated_at < cutoff,
|
|
1182
|
+
);
|
|
1183
|
+
if (stale.length === 0) {
|
|
1184
|
+
if (spoken) neosh.notify(`nothing has been idle for ${days} days`, "info");
|
|
1185
|
+
return 0;
|
|
1186
|
+
}
|
|
1187
|
+
let done = 0;
|
|
1188
|
+
for (const s of stale) {
|
|
1189
|
+
try {
|
|
1190
|
+
await neosh.session.archive(s.id, true);
|
|
1191
|
+
done += 1;
|
|
1192
|
+
} catch {
|
|
1193
|
+
// One that refuses is one the store had a reason to keep — the last open conversation, most
|
|
1194
|
+
// likely. Not worth a warning per conversation on a housekeeping pass.
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (done > 0) {
|
|
1198
|
+
neosh.notify(
|
|
1199
|
+
`archived ${done} ${done === 1 ? "conversation" : "conversations"} idle for ${days}+ days — \`^F\` has them`,
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
return done;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async function remind(neosh: Neosh): Promise<void> {
|
|
1206
|
+
const [days, on] = await Promise.all([
|
|
1207
|
+
neosh.opt.get<number>("archive.retention_days").catch(() => 0),
|
|
1208
|
+
neosh.opt.get<boolean>("archive.remind").catch(() => true),
|
|
1209
|
+
]);
|
|
1210
|
+
if ((days ?? 0) <= 0 || on === false) return;
|
|
1211
|
+
const old = (await collect(neosh)).filter((e) => putAway(e.info) < now() - (days ?? 0) * 86400);
|
|
1212
|
+
if (old.length === 0) return;
|
|
1213
|
+
neosh.notify(
|
|
1214
|
+
`${old.length} archived ${old.length === 1 ? "conversation is" : "conversations are"} older than ${days} days — \`^F\`, then \`^X\``,
|
|
1215
|
+
"info",
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** The by-hand version of the thing this plugin will not do on a timer. */
|
|
1220
|
+
async function sweep(neosh: Neosh): Promise<void> {
|
|
1221
|
+
const days = (await neosh.opt.get<number>("archive.retention_days").catch(() => 0)) ?? 0;
|
|
1222
|
+
if (days <= 0) {
|
|
1223
|
+
neosh.notify("set `archive.retention_days` first — this deletes what is older than it", "warn");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
const old = (await collect(neosh)).filter((e) => putAway(e.info) < now() - days * 86400);
|
|
1227
|
+
if (old.length === 0) {
|
|
1228
|
+
neosh.notify(`nothing has been archived longer than ${days} days`, "info");
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (!(await confirmDelete(neosh, old, "empty-filtered"))) return;
|
|
1232
|
+
await destroy(neosh, old);
|
|
1233
|
+
announced = null;
|
|
1234
|
+
await refreshSidebarRow(neosh);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ---------------------------------------------------------------------------
|
|
1238
|
+
// The door in the sidebar
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
|
|
1241
|
+
const POINT_SECTION = "sidebar.section";
|
|
1242
|
+
const POINT_SIDEBAR_ACTION = "sidebar.action";
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* `a` on every row of the project panel — and, only if you ask for it, a row saying how many.
|
|
1246
|
+
*
|
|
1247
|
+
* The row is **off**. It used to be a door rather than a drawer left open, and it is still the
|
|
1248
|
+
* smallest possible version of one: a single dim line, and only while there is something behind it.
|
|
1249
|
+
* But the column it sits in is your conversations, the thing it announces is by definition the
|
|
1250
|
+
* thing you are finished with, and a permanent line about what you are *not* doing is a line the
|
|
1251
|
+
* panel cannot spare — which is the same argument that took the section out, applied to
|
|
1252
|
+
* what it left behind.
|
|
1253
|
+
*
|
|
1254
|
+
* What replaces it is what the archive was always reached by: `^F` from anywhere, `a` from the
|
|
1255
|
+
* panel — advertised in the panel's key strip, so it is not a key you have to have been told about
|
|
1256
|
+
* — and `^K`. `archive.sidebar = true` puts the row back for anybody who wants the count on screen.
|
|
1257
|
+
*
|
|
1258
|
+
* Both are contributed rather than drawn by the sidebar, which is the surface claim tested on
|
|
1259
|
+
* ourselves: the panel that owns the archive is not the panel that says there is one. Turn this
|
|
1260
|
+
* plugin off and both go with it, rather than leaving a row pointing at a command that has gone.
|
|
1261
|
+
*/
|
|
1262
|
+
async function contributeToSidebar(neosh: Neosh, subscriptions: Disposable[]): Promise<void> {
|
|
1263
|
+
await neosh.ext.contribute(POINT_SIDEBAR_ACTION, "browse", {
|
|
1264
|
+
key: "a",
|
|
1265
|
+
label: "archive",
|
|
1266
|
+
command: "archive.open",
|
|
1267
|
+
on: "any",
|
|
1268
|
+
});
|
|
1269
|
+
subscriptions.push({
|
|
1270
|
+
dispose: () => void neosh.ext.remove(POINT_SIDEBAR_ACTION, "browse").catch(() => {}),
|
|
1271
|
+
});
|
|
1272
|
+
subscriptions.push({
|
|
1273
|
+
dispose: () => void neosh.ext.remove(POINT_SECTION, "archive").catch(() => {}),
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* How many there are, in the sidebar — when it was asked for, and nothing at all when there are
|
|
1279
|
+
* none.
|
|
1280
|
+
*
|
|
1281
|
+
* Off unless `archive.sidebar` says otherwise; see {@link contributeToSidebar}. `null` rather than
|
|
1282
|
+
* `0` for "nothing announced yet", so turning the option off at runtime withdraws the row instead
|
|
1283
|
+
* of matching a remembered count and returning early.
|
|
1284
|
+
*/
|
|
1285
|
+
let announced: number | null = null;
|
|
1286
|
+
|
|
1287
|
+
async function refreshSidebarRow(neosh: Neosh): Promise<void> {
|
|
1288
|
+
const [ascii, wanted] = await Promise.all([
|
|
1289
|
+
neosh.opt.get<boolean>("ui.ascii_only").catch(() => false),
|
|
1290
|
+
neosh.opt.get<boolean>("archive.sidebar").catch(() => false),
|
|
1291
|
+
]);
|
|
1292
|
+
const count = wanted === true ? await archivedCount(neosh) : 0;
|
|
1293
|
+
if (count === announced) return;
|
|
1294
|
+
announced = count;
|
|
1295
|
+
if (count === 0) {
|
|
1296
|
+
await neosh.ext.remove(POINT_SECTION, "archive").catch(() => {});
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
await neosh.ext.contribute(POINT_SECTION, "archive", {
|
|
1300
|
+
at: "below",
|
|
1301
|
+
rows: [{
|
|
1302
|
+
text: ` ${ascii ? "-" : "┈"} Archived`,
|
|
1303
|
+
hl: "Sidebar.Dim",
|
|
1304
|
+
right: { text: `${count} ^F `, hl: "Sidebar.Dim" },
|
|
1305
|
+
command: "archive.open",
|
|
1306
|
+
}],
|
|
1307
|
+
}, { priority: 20 });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ---------------------------------------------------------------------------
|
|
1311
|
+
// Small things
|
|
1312
|
+
// ---------------------------------------------------------------------------
|
|
1313
|
+
|
|
1314
|
+
function basename(path: string): string {
|
|
1315
|
+
const at = path.replace(/\/+$/, "").lastIndexOf("/");
|
|
1316
|
+
return at < 0 ? path : path.slice(at + 1);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Clip to `n` characters, with an ellipsis where something was taken off. */
|
|
1320
|
+
function clip(text: string, n: number): string {
|
|
1321
|
+
const chars = Array.from(text);
|
|
1322
|
+
if (chars.length <= n) return text;
|
|
1323
|
+
return `${chars.slice(0, Math.max(1, n - 1)).join("")}…`;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/** How long ago, in one short word. Empty for anything in the last minute. */
|
|
1327
|
+
function ago(seconds: number): string {
|
|
1328
|
+
if (!Number.isFinite(seconds) || seconds < 60) return "";
|
|
1329
|
+
const minutes = Math.floor(seconds / 60);
|
|
1330
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1331
|
+
const hours = Math.floor(minutes / 60);
|
|
1332
|
+
if (hours < 24) return `${hours}h`;
|
|
1333
|
+
const days = Math.floor(hours / 24);
|
|
1334
|
+
if (days < 7) return `${days}d`;
|
|
1335
|
+
const weeks = Math.floor(days / 7);
|
|
1336
|
+
if (weeks < 9) return `${weeks}w`;
|
|
1337
|
+
const months = Math.floor(days / 30);
|
|
1338
|
+
if (months < 12) return `${months}mo`;
|
|
1339
|
+
return `${Math.floor(days / 365)}y`;
|
|
1340
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neosh/archive",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What you have finished with: a panel to find it in, and the verbs that empty it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"neosh",
|
|
9
|
+
"neosh-plugin"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/neoswarm/neosh.git",
|
|
14
|
+
"directory": "plugins/builtin/archive"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*.ts",
|
|
18
|
+
"plugin.toml",
|
|
19
|
+
"!._*"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/plugin.toml
ADDED