pim-agent 0.9.0 → 0.10.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/bin/pim.ts +1 -1
- package/package.json +2 -2
- package/packages/core/src/extensions/CoreExtensions.ts +19 -16
- package/packages/core/src/extensions/apply-patch/render.ts +16 -55
- package/packages/core/src/extensions/system-prompt/index.ts +29 -21
- package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
- package/packages/core/src/picker/PickerService.ts +23 -1
- package/packages/core/src/picker/token.ts +12 -5
- package/packages/core/src/session/EventLog.ts +22 -79
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +17 -1
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +89 -14
- package/packages/core/src/session/WriteMark.ts +15 -1
- package/packages/core/src/shared/DiffLines.ts +1 -1
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +1 -1
- package/packages/core/src/shared/FileWatch.ts +15 -1
- package/packages/core/src/shared/Git.ts +483 -42
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/Levenshtein.ts +54 -1
- package/packages/core/src/shared/Proc.ts +21 -1
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Updater.ts +21 -5
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +7 -7
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/daemon/src/WebSurface.ts +1 -2
- package/packages/protocol/src/Command.ts +129 -2
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +157 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +90 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +69 -43
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +264 -29
- package/packages/server/src/probe.ts +61 -7
- package/packages/telegram/src/Session.ts +1 -0
- package/packages/tui/src/extensions/command-picker/index.ts +64 -32
- package/packages/tui/src/extensions/footer/index.ts +19 -43
- package/packages/tui/src/extensions/session-lease/index.ts +8 -3
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
- package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
- package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** One indexable word and where it sits in the string it came from. */
|
|
2
|
+
export type SearchToken = {
|
|
3
|
+
/** Lowercased: the corpus itself is never lowered, only its tokens. */
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const RUN = /[\p{L}\p{N}_]+/gu;
|
|
10
|
+
|
|
11
|
+
function scan(text: string): readonly SearchToken[] {
|
|
12
|
+
const tokens: SearchToken[] = [];
|
|
13
|
+
for (const run of text.matchAll(RUN)) {
|
|
14
|
+
const at = run.index;
|
|
15
|
+
const word = run[0];
|
|
16
|
+
const pieces = piecesOf(word);
|
|
17
|
+
for (const [from, to] of pieces) {
|
|
18
|
+
tokens.push({
|
|
19
|
+
text: word.slice(from, to).toLowerCase(),
|
|
20
|
+
start: at + from,
|
|
21
|
+
end: at + to,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (pieces.length > 1) {
|
|
25
|
+
tokens.push({
|
|
26
|
+
text: word.toLowerCase(),
|
|
27
|
+
start: at,
|
|
28
|
+
end: at + word.length,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return tokens;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The words a query asks for: its pieces alone, never the identifier they
|
|
37
|
+
* spell. `SessionLease` asks for `session` and `lease`, which the index holds
|
|
38
|
+
* for `SessionLease` and for `session lease` alike; asking for `sessionlease`
|
|
39
|
+
* too would AND away the second.
|
|
40
|
+
*/
|
|
41
|
+
function words(query: string): readonly string[] {
|
|
42
|
+
const found: string[] = [];
|
|
43
|
+
for (const run of query.matchAll(RUN)) {
|
|
44
|
+
for (const [from, to] of piecesOf(run[0])) {
|
|
45
|
+
const word = run[0].slice(from, to).toLowerCase();
|
|
46
|
+
if (!found.includes(word)) {
|
|
47
|
+
found.push(word);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Half-open slices of one run: `WsGateway` is `ws` and `gateway`, `read_file` is `read` and `file`. */
|
|
55
|
+
function piecesOf(run: string): readonly (readonly [number, number])[] {
|
|
56
|
+
const pieces: (readonly [number, number])[] = [];
|
|
57
|
+
let start = -1;
|
|
58
|
+
for (let at = 0; at < run.length; at++) {
|
|
59
|
+
if (run[at] === "_") {
|
|
60
|
+
if (start >= 0) {
|
|
61
|
+
pieces.push([start, at]);
|
|
62
|
+
start = -1;
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (start < 0) {
|
|
67
|
+
start = at;
|
|
68
|
+
} else if (breaksAt(run, at)) {
|
|
69
|
+
pieces.push([start, at]);
|
|
70
|
+
start = at;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (start >= 0) {
|
|
74
|
+
pieces.push([start, run.length]);
|
|
75
|
+
}
|
|
76
|
+
return pieces;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function breaksAt(run: string, at: number): boolean {
|
|
80
|
+
const here = run[at]!;
|
|
81
|
+
if (!isUpper(here)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const before = run[at - 1]!;
|
|
85
|
+
const after = run[at + 1];
|
|
86
|
+
return !isUpper(before) || (after !== undefined && isLower(after));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isUpper(char: string): boolean {
|
|
90
|
+
return char !== char.toLowerCase() && char === char.toUpperCase();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isLower(char: string): boolean {
|
|
94
|
+
return char !== char.toUpperCase() && char === char.toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const SearchTokens = { scan, words };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { Attachments } from "../attachments/Attachments";
|
|
4
|
+
import { MessageText } from "./MessageText";
|
|
5
|
+
|
|
6
|
+
/** What the session catalogue shows for one file without opening a session. */
|
|
7
|
+
export type SessionDigest = {
|
|
8
|
+
/** The session's name if it has one, else its first user message; absent when it has neither. */
|
|
9
|
+
readonly title?: string;
|
|
10
|
+
/** True when `title` is a name somebody wrote rather than the opening message. */
|
|
11
|
+
readonly named?: true;
|
|
12
|
+
/** When the agent last wrote; it moves with an in-flight turn, so read it before starting one. */
|
|
13
|
+
readonly settledAt?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The digest before it is clamped, so a reader that tails an appended region
|
|
18
|
+
* can fold that region into what an earlier read of the same file found.
|
|
19
|
+
*/
|
|
20
|
+
export type DigestParts = {
|
|
21
|
+
readonly name?: string;
|
|
22
|
+
/** The opening ask, unclamped. */
|
|
23
|
+
readonly opening?: string;
|
|
24
|
+
readonly settledAt?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A session file's durable bytes, undecoded: a whole session is megabytes of
|
|
29
|
+
* tool output and the digest wants a handful of lines of it, so the search
|
|
30
|
+
* runs over the bytes and only the lines it keeps become strings.
|
|
31
|
+
*/
|
|
32
|
+
export type Durable = {
|
|
33
|
+
readonly bytes: Buffer;
|
|
34
|
+
/** Past the last newline; a trailing fragment is a write in progress and is not a line. */
|
|
35
|
+
readonly end: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const PROBE_LINES = 16;
|
|
39
|
+
|
|
40
|
+
const TAIL_LINES = 10;
|
|
41
|
+
|
|
42
|
+
const TITLE_LIMIT = 120;
|
|
43
|
+
|
|
44
|
+
/** Cheap reject before decoding a line: pi writes the discriminant verbatim. */
|
|
45
|
+
const NAMED = Buffer.from('"session_info"');
|
|
46
|
+
|
|
47
|
+
export const NEWLINE = 0x0a;
|
|
48
|
+
|
|
49
|
+
function durable(read: Uint8Array): Durable | undefined {
|
|
50
|
+
const bytes = Buffer.from(read.buffer, read.byteOffset, read.byteLength);
|
|
51
|
+
const end = bytes.lastIndexOf(NEWLINE) + 1;
|
|
52
|
+
return end === 0 ? undefined : { bytes, end };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function partsOf(body: Durable): DigestParts {
|
|
56
|
+
return {
|
|
57
|
+
name: nameOf(body),
|
|
58
|
+
opening: firstUserMessage(headLinesOf(body, PROBE_LINES)),
|
|
59
|
+
settledAt: lastAgentTime(tailLinesOf(body, TAIL_LINES).reverse()),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An append only adds, so the opening ask stands and a later name or agent line wins. */
|
|
64
|
+
function merge(before: DigestParts, appended: DigestParts): DigestParts {
|
|
65
|
+
return {
|
|
66
|
+
name: appended.name ?? before.name,
|
|
67
|
+
opening: before.opening ?? appended.opening,
|
|
68
|
+
settledAt: appended.settledAt ?? before.settledAt,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function of({ name, opening, settledAt }: DigestParts): SessionDigest {
|
|
73
|
+
const title = name ?? opening;
|
|
74
|
+
return {
|
|
75
|
+
...(title === undefined ? {} : { title: clamp(title) }),
|
|
76
|
+
...(name === undefined ? {} : { named: true as const }),
|
|
77
|
+
...(settledAt === undefined ? {} : { settledAt }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The last `session_info` line, found by walking the marker's occurrences backwards; a line that merely quotes the marker parses as something else, and the walk carries on past it. */
|
|
82
|
+
function nameOf({ bytes, end }: Durable): string | undefined {
|
|
83
|
+
let at = end - 1;
|
|
84
|
+
while (at >= 0) {
|
|
85
|
+
const found = bytes.lastIndexOf(NAMED, at);
|
|
86
|
+
if (found === -1) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const from = bytes.lastIndexOf(NEWLINE, found) + 1;
|
|
90
|
+
const to = bytes.indexOf(NEWLINE, found);
|
|
91
|
+
const entry = parseSessionEntries(bytes.toString("utf8", from, to))[0];
|
|
92
|
+
if (entry?.type === "session_info") {
|
|
93
|
+
return entry.name?.trim() || undefined;
|
|
94
|
+
}
|
|
95
|
+
at = from - 1;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function headLinesOf({ bytes, end }: Durable, count: number): string[] {
|
|
101
|
+
let at = 0;
|
|
102
|
+
for (let taken = 0; taken < count && at < end; taken++) {
|
|
103
|
+
at = bytes.indexOf(NEWLINE, at) + 1;
|
|
104
|
+
}
|
|
105
|
+
return split(bytes.toString("utf8", 0, at));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tailLinesOf({ bytes, end }: Durable, count: number): string[] {
|
|
109
|
+
let at = end - 1;
|
|
110
|
+
for (let taken = 0; taken < count && at > 0; taken++) {
|
|
111
|
+
at = bytes.lastIndexOf(NEWLINE, at - 1);
|
|
112
|
+
}
|
|
113
|
+
return split(bytes.toString("utf8", at + 1, end));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function split(text: string): string[] {
|
|
117
|
+
const lines = text.split("\n");
|
|
118
|
+
lines.pop();
|
|
119
|
+
return lines;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function lastAgentTime(lines: readonly string[]): number | undefined {
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const entry = parseSessionEntries(line)[0];
|
|
125
|
+
if (entry?.type !== "message") {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const role = entry.message.role;
|
|
129
|
+
if (role === "assistant" || role === "toolResult") {
|
|
130
|
+
const at = Date.parse(entry.timestamp);
|
|
131
|
+
return Number.isNaN(at) ? undefined : at;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function clamp(text: string): string {
|
|
138
|
+
return text.length > TITLE_LIMIT
|
|
139
|
+
? `${text.slice(0, TITLE_LIMIT).trimEnd()}…`
|
|
140
|
+
: text;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Over the head window the caller has already cut, not the whole file. */
|
|
144
|
+
function firstUserMessage(lines: readonly string[]): string | undefined {
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const entry = parseSessionEntries(line)[0];
|
|
147
|
+
if (entry?.type !== "message" || entry.message.role !== "user") {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const said = Attachments.parse(MessageText.textOf(entry.message.content));
|
|
151
|
+
const text =
|
|
152
|
+
said.text.trim() ||
|
|
153
|
+
said.files.map((file) => Attachments.nameOf(file.path)).join(", ");
|
|
154
|
+
if (text !== "") {
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const SessionDigest = { durable, partsOf, merge, of, nameOf };
|
|
@@ -22,12 +22,14 @@ import { unlink } from "node:fs/promises";
|
|
|
22
22
|
import { CoreExtensions } from "../extensions/CoreExtensions";
|
|
23
23
|
import { Directories } from "../shared/Directories";
|
|
24
24
|
import { FuzzyMatcher, type FuzzyCandidate } from "../shared/FuzzyMatcher";
|
|
25
|
+
import type { Surface } from "../shared/Surface";
|
|
25
26
|
import { EventLog } from "./EventLog";
|
|
26
27
|
import {
|
|
27
28
|
SessionLease,
|
|
28
29
|
type LeaseFrontend,
|
|
29
30
|
type LeaseRecord,
|
|
30
31
|
} from "./SessionLease";
|
|
32
|
+
import { SessionName } from "./SessionName";
|
|
31
33
|
import { WriteMark } from "./WriteMark";
|
|
32
34
|
|
|
33
35
|
/** What the agent is doing right now. */
|
|
@@ -93,6 +95,8 @@ export type SessionHostDeps = {
|
|
|
93
95
|
readonly mainSessionPath?: () => string;
|
|
94
96
|
readonly isolatedSessionPath?: () => string;
|
|
95
97
|
readonly systemInstruction?: () => Promise<string | undefined>;
|
|
98
|
+
/** Where this host's human is reading; surfaces in the system prompt's environment block. */
|
|
99
|
+
readonly surface?: Surface;
|
|
96
100
|
readonly customTools?: (
|
|
97
101
|
context: CustomToolContext
|
|
98
102
|
) => readonly ToolDefinition[];
|
|
@@ -399,6 +403,18 @@ export class SessionHost {
|
|
|
399
403
|
});
|
|
400
404
|
}
|
|
401
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Rename this session through pi's own session name, so its `/resume` picker
|
|
408
|
+
* shows it too; `null` clears it. Answers with the name pi kept.
|
|
409
|
+
*/
|
|
410
|
+
public setName(name: string | null): Promise<string | undefined> {
|
|
411
|
+
return this.withLease(async () => {
|
|
412
|
+
const agent = await this.ensureCached();
|
|
413
|
+
agent.setSessionName(SessionName.normalise(name));
|
|
414
|
+
return agent.sessionManager.getSessionName();
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
402
418
|
public compact(customInstructions?: string): Promise<SessionCompactResult> {
|
|
403
419
|
return this.withLease(async (): Promise<SessionCompactResult> => {
|
|
404
420
|
const agent = await this.ensureCached();
|
|
@@ -716,7 +732,7 @@ export class SessionHost {
|
|
|
716
732
|
agentDir: this.deps.agentDir,
|
|
717
733
|
settingsManager,
|
|
718
734
|
// Load core tools in-process: a disk-loaded copy registers views into a second `Tools`.
|
|
719
|
-
extensionFactories: CoreExtensions.gated(),
|
|
735
|
+
extensionFactories: CoreExtensions.gated(this.deps.surface),
|
|
720
736
|
appendSystemPromptOverride: (base) => {
|
|
721
737
|
return promptRef.wrapped ? [...base, promptRef.wrapped] : base;
|
|
722
738
|
},
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { Fs } from "../shared/Fs";
|
|
4
|
+
import { Json } from "../shared/Json";
|
|
5
|
+
import { Paths } from "../shared/Paths";
|
|
6
|
+
|
|
7
|
+
export type SessionEntry = {
|
|
8
|
+
readonly archived?: boolean;
|
|
9
|
+
/** Sticky: set by hand, and only a read clears it. */
|
|
10
|
+
readonly unread?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ProjectEntry = {
|
|
14
|
+
readonly pinned?: boolean;
|
|
15
|
+
/** The sidebar group stands unfolded; absent is folded, which is where a project starts. */
|
|
16
|
+
readonly expanded?: boolean;
|
|
17
|
+
/** What to call the directory instead of its base name; the directory itself is never touched. */
|
|
18
|
+
readonly label?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The pin flags and the order they are shown in, from one read of the file. */
|
|
22
|
+
export type Pinning = {
|
|
23
|
+
/** Keyed by absolute cwd. */
|
|
24
|
+
readonly projects: ReadonlyMap<string, ProjectEntry>;
|
|
25
|
+
/** The pinned directories, in the order they are shown. */
|
|
26
|
+
readonly order: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Stored = {
|
|
30
|
+
readonly version: 1;
|
|
31
|
+
readonly sessions: Record<string, SessionEntry>;
|
|
32
|
+
readonly projects: Record<string, ProjectEntry>;
|
|
33
|
+
/**
|
|
34
|
+
* Where the pinned directories sort, and only that: `projects` stays the
|
|
35
|
+
* truth of whether one is pinned. An entry here that is not pinned is
|
|
36
|
+
* ignored and a pin it has never heard of still sorts, so a file written by
|
|
37
|
+
* a pim that predates the order — or by one that does not write it — needs
|
|
38
|
+
* no migration.
|
|
39
|
+
*/
|
|
40
|
+
readonly pins: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Loaded = {
|
|
44
|
+
readonly sessions: Map<string, SessionEntry>;
|
|
45
|
+
readonly projects: Map<string, ProjectEntry>;
|
|
46
|
+
pins: string[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const NONE: SessionEntry = {};
|
|
50
|
+
|
|
51
|
+
/** Per-session and per-project overrides, shared by every surface on a machine. */
|
|
52
|
+
export class SessionMeta {
|
|
53
|
+
private readonly file: string;
|
|
54
|
+
private readonly writes = Fs.serialised();
|
|
55
|
+
|
|
56
|
+
/** Defaults to `~/.pim/sessions.json`. */
|
|
57
|
+
public constructor(path?: string) {
|
|
58
|
+
this.file = path ?? join(Paths.pimHomeDir(), "sessions.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public async of(sessionId: string): Promise<SessionEntry> {
|
|
62
|
+
return (await this.sessions()).get(sessionId) ?? NONE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One read for a whole listing; `of` per row is one read per row. */
|
|
66
|
+
public async sessions(): Promise<ReadonlyMap<string, SessionEntry>> {
|
|
67
|
+
return (await this.read()).sessions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The pinned directories in display order; a listing wants both halves, and this is one read for them. */
|
|
71
|
+
public async pinning(): Promise<Pinning> {
|
|
72
|
+
const loaded = await this.read();
|
|
73
|
+
return { projects: loaded.projects, order: ordered(loaded) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The pinned directories in display order. */
|
|
77
|
+
public async pins(): Promise<readonly string[]> {
|
|
78
|
+
return ordered(await this.read());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public setArchived(sessionId: string, archived: boolean): Promise<void> {
|
|
82
|
+
return this.mutate((loaded) =>
|
|
83
|
+
put(loaded.sessions, sessionId, { archived })
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public setUnread(sessionId: string, unread: boolean): Promise<void> {
|
|
88
|
+
return this.mutate((loaded) => put(loaded.sessions, sessionId, { unread }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A new pin goes to the top, where you have just put it; an old one leaves the order with the flag. */
|
|
92
|
+
public setPinned(cwd: string, pinned: boolean): Promise<void> {
|
|
93
|
+
return this.mutate((loaded) => {
|
|
94
|
+
const at = loaded.pins.indexOf(cwd);
|
|
95
|
+
if (at !== -1) {
|
|
96
|
+
loaded.pins.splice(at, 1);
|
|
97
|
+
}
|
|
98
|
+
if (pinned) {
|
|
99
|
+
loaded.pins.unshift(cwd);
|
|
100
|
+
}
|
|
101
|
+
return put(loaded.projects, cwd, { pinned });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Folds a project's group, or unfolds it. Kept beside the pin rather than
|
|
107
|
+
* in the browser, so the fold a phone made is the fold a desktop opens to.
|
|
108
|
+
*/
|
|
109
|
+
public setExpanded(cwd: string, expanded: boolean): Promise<void> {
|
|
110
|
+
return this.mutate((loaded) => put(loaded.projects, cwd, { expanded }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Names the project for the sidebar; `null` puts it back to its base name. */
|
|
114
|
+
public setLabel(cwd: string, label: string | null): Promise<void> {
|
|
115
|
+
return this.mutate((loaded) =>
|
|
116
|
+
put(loaded.projects, cwd, { label: label ?? "" })
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Takes the whole order rather than a move, so two surfaces settle on the last one written. */
|
|
121
|
+
public setPinOrder(order: readonly string[]): Promise<void> {
|
|
122
|
+
return this.mutate((loaded) => {
|
|
123
|
+
loaded.pins = [...new Set(order)].filter(
|
|
124
|
+
(cwd) => loaded.projects.get(cwd)?.pinned === true
|
|
125
|
+
);
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Drops the sessions that are gone; a pin outlives every session of its project. */
|
|
131
|
+
public prune(alive: ReadonlySet<string>): Promise<void> {
|
|
132
|
+
return this.mutate((loaded) => {
|
|
133
|
+
let dropped = false;
|
|
134
|
+
for (const sessionId of loaded.sessions.keys()) {
|
|
135
|
+
if (!alive.has(sessionId)) {
|
|
136
|
+
loaded.sessions.delete(sessionId);
|
|
137
|
+
dropped = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return dropped;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Settles the writes taken so far, for a clean stop. */
|
|
145
|
+
public async flush(): Promise<void> {
|
|
146
|
+
await this.writes.run(async () => undefined);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async read(): Promise<Loaded> {
|
|
150
|
+
const raw = Json.asRecord(
|
|
151
|
+
await Fs.readJsonOr<unknown>(this.file, undefined)
|
|
152
|
+
);
|
|
153
|
+
if (raw?.version !== 1) {
|
|
154
|
+
return { sessions: new Map(), projects: new Map(), pins: [] };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
sessions: parseAll(raw.sessions, parseSession),
|
|
158
|
+
projects: parseAll(raw.projects, parseProject),
|
|
159
|
+
pins: Array.isArray(raw.pins)
|
|
160
|
+
? raw.pins.filter((cwd) => typeof cwd === "string")
|
|
161
|
+
: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Re-reads under the lock, so a write by another process survives this one. */
|
|
166
|
+
private async mutate(update: (loaded: Loaded) => boolean): Promise<void> {
|
|
167
|
+
await this.writes.run(async () => {
|
|
168
|
+
const loaded = await this.read();
|
|
169
|
+
if (!update(loaded)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await Paths.ensurePimHome();
|
|
173
|
+
await Fs.writeJson(this.file, {
|
|
174
|
+
version: 1,
|
|
175
|
+
sessions: Object.fromEntries(loaded.sessions),
|
|
176
|
+
projects: Object.fromEntries(loaded.projects),
|
|
177
|
+
pins: loaded.pins,
|
|
178
|
+
} satisfies Stored);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The pinned directories in display order: the ones the stored order names,
|
|
185
|
+
* then any pin it has not heard of, by path. A duplicate in the order is
|
|
186
|
+
* taken once, at the first place it appears.
|
|
187
|
+
*/
|
|
188
|
+
function ordered({ projects, pins }: Loaded): readonly string[] {
|
|
189
|
+
const unplaced = new Set(
|
|
190
|
+
[...projects]
|
|
191
|
+
.filter(([, entry]) => entry.pinned === true)
|
|
192
|
+
.map(([cwd]) => cwd)
|
|
193
|
+
);
|
|
194
|
+
const listed = pins.filter((cwd) => unplaced.delete(cwd));
|
|
195
|
+
return [...listed, ...[...unplaced].sort()];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function put<T extends object>(
|
|
199
|
+
into: Map<string, T>,
|
|
200
|
+
key: string,
|
|
201
|
+
patch: T
|
|
202
|
+
): boolean {
|
|
203
|
+
const next = onlySet({ ...into.get(key), ...patch });
|
|
204
|
+
if (Object.keys(next).length === 0) {
|
|
205
|
+
into.delete(key);
|
|
206
|
+
} else {
|
|
207
|
+
into.set(key, next);
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Every field is opt-in, so a false flag or an empty name is the same as an absent one. */
|
|
213
|
+
function onlySet<T extends object>(entry: T): T {
|
|
214
|
+
return Object.fromEntries(
|
|
215
|
+
Object.entries(entry).filter(
|
|
216
|
+
([, value]) =>
|
|
217
|
+
value === true || (typeof value === "string" && value !== "")
|
|
218
|
+
)
|
|
219
|
+
) as T;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseAll<T>(
|
|
223
|
+
value: unknown,
|
|
224
|
+
parse: (value: unknown) => T | undefined
|
|
225
|
+
): Map<string, T> {
|
|
226
|
+
const parsed = new Map<string, T>();
|
|
227
|
+
for (const [key, entry] of Object.entries(Json.asRecord(value) ?? {})) {
|
|
228
|
+
const valid = parse(entry);
|
|
229
|
+
if (valid !== undefined) {
|
|
230
|
+
parsed.set(key, valid);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return parsed;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseSession(value: unknown): SessionEntry | undefined {
|
|
237
|
+
const raw = Json.asRecord(value);
|
|
238
|
+
if (raw === undefined || !isFlag(raw.archived) || !isFlag(raw.unread)) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
return onlySet({ archived: raw.archived, unread: raw.unread });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseProject(value: unknown): ProjectEntry | undefined {
|
|
245
|
+
const raw = Json.asRecord(value);
|
|
246
|
+
if (
|
|
247
|
+
raw === undefined ||
|
|
248
|
+
!isFlag(raw.pinned) ||
|
|
249
|
+
!isFlag(raw.expanded) ||
|
|
250
|
+
!isName(raw.label)
|
|
251
|
+
) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return onlySet({
|
|
255
|
+
pinned: raw.pinned,
|
|
256
|
+
expanded: raw.expanded,
|
|
257
|
+
label: raw.label,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function isFlag(value: unknown): value is boolean | undefined {
|
|
262
|
+
return value === undefined || typeof value === "boolean";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isName(value: unknown): value is string | undefined {
|
|
266
|
+
return value === undefined || typeof value === "string";
|
|
267
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twice the width a sidebar row can show, so a name is never silently cut at the
|
|
3
|
+
* length it happens to render at, and a pasted essay still cannot become one.
|
|
4
|
+
*/
|
|
5
|
+
const LIMIT = 80;
|
|
6
|
+
|
|
7
|
+
/** What pi should store for this name: whitespace collapsed, controls gone, `""` to clear. */
|
|
8
|
+
function normalise(name: string | null | undefined): string {
|
|
9
|
+
const clean = (name ?? "")
|
|
10
|
+
.replace(/\p{Cc}/gu, " ")
|
|
11
|
+
.replace(/\s+/gu, " ")
|
|
12
|
+
.trim();
|
|
13
|
+
// By code point: a cap that splits a surrogate pair stores half a character.
|
|
14
|
+
return [...clean].slice(0, LIMIT).join("").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The one spelling of a session name pim writes into pi's `session_info`. */
|
|
18
|
+
export const SessionName = { normalise };
|