@gavin-hu/my-pi-agent 0.13.1 → 0.15.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/README.md +66 -41
- package/README.zh-CN.md +53 -36
- package/extensions/bytefly/README.md +321 -186
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/config.ts +3 -2
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +33 -10
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +6 -6
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +7 -12
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +22 -2
- package/extensions/bytefly/panel/format.ts +17 -21
- package/extensions/bytefly/panel/settings-runtime.ts +86 -22
- package/extensions/bytefly/panel/settings.ts +279 -46
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
- package/extensions/bytefly/panel/tui.ts +176 -166
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +17 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +3 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +106 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +118 -8
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +62 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +116 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +25 -0
- package/extensions/subagent/run.ts +113 -49
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +23 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +16 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +3 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +83 -12
- package/lib/errors.ts +12 -0
- package/lib/format.ts +56 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +23 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/themes/bytefly-dark.json +2 -3
- package/themes/bytefly-light.json +2 -3
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
|
@@ -5,21 +5,21 @@
|
|
|
5
5
|
* The screen is the timeline's sibling — same dock frame, same cursor, same
|
|
6
6
|
* width-safe row grammar — so a partial restore reads as one more step in
|
|
7
7
|
* `/rewind` rather than a second product. Rows are selected by default, which
|
|
8
|
-
* makes `Enter` here reproduce the whole-tree restore exactly.
|
|
8
|
+
* makes `Enter` here reproduce the whole-tree restore exactly. The frame, the
|
|
9
|
+
* cursor, and the toggle keys come from {@link ChecklistScreen}.
|
|
9
10
|
*
|
|
10
11
|
* The component owns only the selection: paths in, a chosen subset out through
|
|
11
12
|
* `onClose`, and the restore itself stays behind the confirmation that follows.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { dockListFrame, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
16
|
+
import { sanitizeLine } from "../../lib/format.ts";
|
|
17
|
+
import { screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
18
18
|
import { clip } from "../../lib/tui/columns.ts";
|
|
19
19
|
import { SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
20
20
|
import { checkbox, listRow } from "../../lib/tui/rows.ts";
|
|
21
|
-
import { titleRule } from "../../lib/tui/rules.ts";
|
|
22
21
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
22
|
+
import { ChecklistScreen } from "./checklist.ts";
|
|
23
23
|
import { baseName, expandGroup, groupPaths, SELECTION_MARKS, type ChangedPath, type PathGroup } from "./paths.ts";
|
|
24
24
|
|
|
25
25
|
export interface PathPickerOptions {
|
|
@@ -32,119 +32,79 @@ export interface PathPickerOptions {
|
|
|
32
32
|
viewportRows?: ViewportRowsSource;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Rows the screen shows when the terminal height is unknown. */
|
|
36
|
-
const SCREEN_DEFAULT_ITEMS = 12;
|
|
37
|
-
/** Header, summary, blanks, and footer rows around the list. */
|
|
38
|
-
const SCREEN_CHROME_ROWS = 6;
|
|
39
|
-
|
|
40
35
|
/** One rendered row: a directory header or a file under one. */
|
|
41
36
|
type Row = { kind: "group"; group: PathGroup } | { kind: "file"; path: ChangedPath; dir: string };
|
|
42
37
|
|
|
43
38
|
/** The checklist of changed paths. */
|
|
44
|
-
export class PathPickerComponent
|
|
45
|
-
private readonly
|
|
39
|
+
export class PathPickerComponent extends ChecklistScreen<Row> {
|
|
40
|
+
private readonly view: PathPickerOptions;
|
|
46
41
|
private readonly groups: PathGroup[];
|
|
47
|
-
private readonly
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.
|
|
42
|
+
private readonly flat: readonly Row[];
|
|
43
|
+
|
|
44
|
+
constructor(view: PathPickerOptions) {
|
|
45
|
+
super({
|
|
46
|
+
title: "Restore paths",
|
|
47
|
+
theme: view.theme,
|
|
48
|
+
requestRender: view.requestRender,
|
|
49
|
+
viewportRows: view.viewportRows,
|
|
50
|
+
});
|
|
51
|
+
this.view = view;
|
|
52
|
+
this.groups = groupPaths(view.paths);
|
|
53
|
+
this.flat = this.groups.flatMap((group): Row[] => [
|
|
57
54
|
{ kind: "group", group },
|
|
58
55
|
...group.paths.map((path): Row => ({ kind: "file", path, dir: group.dir })),
|
|
59
56
|
]);
|
|
60
57
|
}
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return group.paths.every((path) => this.chosen.has(path.path));
|
|
59
|
+
protected rows(): readonly Row[] {
|
|
60
|
+
return this.flat;
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (on) this.chosen.add(path);
|
|
71
|
-
else this.chosen.delete(path);
|
|
72
|
-
}
|
|
63
|
+
/** A group header's key is never selected: it toggles its files instead. */
|
|
64
|
+
protected keyOf(row: Row): string {
|
|
65
|
+
return row.kind === "group" ? row.group.dir : row.path.path;
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (navigateList(data, this.cursor, this.rows.length)) return;
|
|
82
|
-
if (data === " ") {
|
|
83
|
-
const row = this.rows[this.cursor.selected];
|
|
84
|
-
if (row?.kind === "group") this.setGroup(row.group, !this.groupOn(row.group));
|
|
85
|
-
else if (row) {
|
|
86
|
-
if (this.chosen.has(row.path.path)) this.chosen.delete(row.path.path);
|
|
87
|
-
else this.chosen.add(row.path.path);
|
|
88
|
-
}
|
|
89
|
-
this.options.requestRender();
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
if (data === "a") {
|
|
93
|
-
for (const path of this.options.paths) this.chosen.add(path.path);
|
|
94
|
-
this.options.requestRender();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
if (data === "n") {
|
|
98
|
-
this.chosen.clear();
|
|
99
|
-
this.options.requestRender();
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (matchesKey(data, Key.enter)) this.options.onClose([...this.chosen]);
|
|
68
|
+
/**
|
|
69
|
+
* Every path starts selected, so confirming without touching anything means
|
|
70
|
+
* "restore it all" — the behavior `/rewind` had before the checklist existed.
|
|
71
|
+
*/
|
|
72
|
+
protected initialSelection(): readonly string[] {
|
|
73
|
+
return this.groups.flatMap(expandGroup);
|
|
103
74
|
}
|
|
104
75
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return scrollList(event, this.cursor, this.rows.length);
|
|
76
|
+
protected selectableKeys(): readonly string[] {
|
|
77
|
+
return this.view.paths.map((path) => path.path);
|
|
108
78
|
}
|
|
109
79
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const w = Math.max(1, width);
|
|
117
|
-
const header = titleRule(this.theme, w, "Restore paths");
|
|
118
|
-
const cursor = this.cursor;
|
|
119
|
-
cursor.visible = fitRows(this.options.viewportRows, this.rows.length, SCREEN_CHROME_ROWS, SCREEN_DEFAULT_ITEMS);
|
|
120
|
-
const { start, end } = cursor.window(this.rows.length);
|
|
121
|
-
|
|
122
|
-
const body: string[] = [];
|
|
123
|
-
for (let i = start; i < end; i++) body.push(this.rowLine(w, this.rows[i], i === cursor.selected));
|
|
124
|
-
|
|
125
|
-
const total = this.options.paths.length;
|
|
126
|
-
const selected = this.chosen.size;
|
|
127
|
-
return dockListFrame({
|
|
128
|
-
header,
|
|
129
|
-
summary: clip(
|
|
130
|
-
` ${paint(this.theme, "meta", `${selected} of ${total} selected${SEPARATORS.item}Space toggles a file or a directory`)}`,
|
|
131
|
-
w,
|
|
132
|
-
),
|
|
133
|
-
body,
|
|
134
|
-
range: cursor.clipped(this.rows.length)
|
|
135
|
-
? formatRange(this.theme, w, { start, end, total: this.rows.length })
|
|
136
|
-
: undefined,
|
|
137
|
-
detail: this.detail(w),
|
|
138
|
-
hint: this.hint(w),
|
|
139
|
-
});
|
|
80
|
+
protected onEnter(selection: string[]): void {
|
|
81
|
+
this.view.onClose(selection);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protected onCancel(): void {
|
|
85
|
+
this.view.onClose(undefined);
|
|
140
86
|
}
|
|
141
87
|
|
|
142
|
-
|
|
143
|
-
|
|
88
|
+
/** Space on a directory header toggles every file under it. */
|
|
89
|
+
protected override toggle(row: Row): void {
|
|
90
|
+
if (row.kind === "file") {
|
|
91
|
+
super.toggle(row);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const on = !this.groupOn(row.group);
|
|
95
|
+
for (const path of expandGroup(row.group)) {
|
|
96
|
+
if (on) this.chosen.add(path);
|
|
97
|
+
else this.chosen.delete(path);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether every file under `group` is selected. */
|
|
102
|
+
private groupOn(group: PathGroup): boolean {
|
|
103
|
+
return group.paths.every((path) => this.chosen.has(path.path));
|
|
144
104
|
}
|
|
145
105
|
|
|
146
106
|
/** One row: a directory header with its count, or a file with its change mark. */
|
|
147
|
-
|
|
107
|
+
protected rowLine(row: Row, width: number, selected: boolean): string {
|
|
148
108
|
const tone = selected ? "accent" : row.kind === "group" ? "meta" : "label";
|
|
149
109
|
if (row.kind === "group") {
|
|
150
110
|
const count = row.group.paths.length;
|
|
@@ -170,10 +130,15 @@ export class PathPickerComponent implements Component {
|
|
|
170
130
|
});
|
|
171
131
|
}
|
|
172
132
|
|
|
133
|
+
protected summaryText(): string {
|
|
134
|
+
const total = this.view.paths.length;
|
|
135
|
+
return `${this.chosen.size} of ${total} selected${SEPARATORS.item}Space toggles a file or a directory`;
|
|
136
|
+
}
|
|
137
|
+
|
|
173
138
|
/** What the focused row will do, in words. */
|
|
174
|
-
|
|
175
|
-
const row = this.
|
|
176
|
-
if (!row) return
|
|
139
|
+
protected detailLines(width: number): string[] {
|
|
140
|
+
const row = this.focused;
|
|
141
|
+
if (!row) return [];
|
|
177
142
|
const text =
|
|
178
143
|
row.kind === "group"
|
|
179
144
|
? `Space selects or clears all ${row.group.paths.length} file${row.group.paths.length === 1 ? "" : "s"} in this directory.`
|
|
@@ -181,12 +146,14 @@ export class PathPickerComponent implements Component {
|
|
|
181
146
|
? "A — created after the snapshot; restoring removes it."
|
|
182
147
|
: row.path.change === "deleted"
|
|
183
148
|
? "D — deleted since the snapshot; restoring brings it back."
|
|
184
|
-
:
|
|
149
|
+
: row.path.change === "renamed"
|
|
150
|
+
? `R — renamed from ${sanitizeLine(row.path.from)}; restoring brings the old path back and removes this one.`
|
|
151
|
+
: "M — modified since the snapshot; restoring rewrites it.";
|
|
185
152
|
return [clip(` ${paint(this.theme, "meta", text)}`, width)];
|
|
186
153
|
}
|
|
187
154
|
|
|
188
155
|
/** The key-hint row, with the primary action in the accent role. */
|
|
189
|
-
|
|
156
|
+
protected hint(width: number): string {
|
|
190
157
|
return screenHint(this.theme, width, [
|
|
191
158
|
"Esc cancel",
|
|
192
159
|
{ text: "Enter restore", color: "accent" },
|
|
@@ -6,27 +6,36 @@
|
|
|
6
6
|
* cannot disagree about what changed. Pure: parsing, grouping, marks, and the
|
|
7
7
|
* counts `RestoreSummary` reports.
|
|
8
8
|
*
|
|
9
|
-
* A rename arrives as `R100\told\tnew
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* A rename arrives as `R100\told\tnew` and stays one entry: `path` is the
|
|
10
|
+
* destination, `from` the path it had in the snapshot, and the mark is `R`.
|
|
11
|
+
* Keeping both sides on one entry is what makes a single selection atomic — the
|
|
12
|
+
* restore recreates `from` and deletes `path`, so neither side can be left
|
|
13
|
+
* behind. `entryPaths` is the one place that names both.
|
|
13
14
|
*/
|
|
14
15
|
|
|
16
|
+
/** The single-path statuses, which never carry a source path. */
|
|
17
|
+
type SimpleChange = "modified" | "deleted" | "added";
|
|
18
|
+
|
|
15
19
|
/** What a restore does to one path. */
|
|
16
|
-
export type PathChange =
|
|
20
|
+
export type PathChange = SimpleChange | "renamed";
|
|
17
21
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
/** Repository-relative path, as git reports it. */
|
|
22
|
+
/** Fields every changed path carries. */
|
|
23
|
+
interface ChangedPathBase {
|
|
24
|
+
/** Repository-relative path, as git reports it; a rename's destination. */
|
|
21
25
|
path: string;
|
|
22
|
-
change: PathChange;
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
/** One path the snapshot differs on, and how. A rename always names its source. */
|
|
29
|
+
export type ChangedPath =
|
|
30
|
+
| (ChangedPathBase & { change: SimpleChange })
|
|
31
|
+
| (ChangedPathBase & { change: "renamed"; from: string });
|
|
32
|
+
|
|
25
33
|
/** The trailing mark for each change in the checklist. */
|
|
26
34
|
export const SELECTION_MARKS: Readonly<Record<PathChange, string>> = {
|
|
27
35
|
modified: "M",
|
|
28
36
|
deleted: "D",
|
|
29
37
|
added: "A",
|
|
38
|
+
renamed: "R",
|
|
30
39
|
};
|
|
31
40
|
|
|
32
41
|
/** One directory's files, for the checklist's grouping. */
|
|
@@ -38,7 +47,7 @@ export interface PathGroup {
|
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/** The status letter → change kind, for the single-path statuses. */
|
|
41
|
-
function changeOf(letter: string):
|
|
50
|
+
function changeOf(letter: string): SimpleChange | undefined {
|
|
42
51
|
switch (letter) {
|
|
43
52
|
case "M":
|
|
44
53
|
case "T": // a type change (file ↔ symlink) rewrites the path
|
|
@@ -53,10 +62,10 @@ function changeOf(letter: string): PathChange | undefined {
|
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/**
|
|
56
|
-
* Parse `git diff --name-status` output. A rename
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
65
|
+
* Parse `git diff --name-status` output. A rename keeps both of its paths on
|
|
66
|
+
* one entry and a copy becomes its destination only — the source is untouched,
|
|
67
|
+
* so it is not a changed path. An unknown status letter is skipped rather than
|
|
68
|
+
* guessed at.
|
|
60
69
|
*/
|
|
61
70
|
export function parseNameStatus(stdout: string): ChangedPath[] {
|
|
62
71
|
const changed: ChangedPath[] = [];
|
|
@@ -67,8 +76,8 @@ export function parseNameStatus(stdout: string): ChangedPath[] {
|
|
|
67
76
|
const letter = status[0] ?? "";
|
|
68
77
|
if (letter === "R" || letter === "C") {
|
|
69
78
|
if (!second) continue;
|
|
70
|
-
if (letter === "R") changed.push({ path:
|
|
71
|
-
changed.push({ path: second, change: "added" });
|
|
79
|
+
if (letter === "R") changed.push({ path: second, change: "renamed", from: first });
|
|
80
|
+
else changed.push({ path: second, change: "added" });
|
|
72
81
|
continue;
|
|
73
82
|
}
|
|
74
83
|
const kind = changeOf(letter);
|
|
@@ -113,9 +122,19 @@ export function expandGroup(group: PathGroup): string[] {
|
|
|
113
122
|
}
|
|
114
123
|
|
|
115
124
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
125
|
+
* Every path an entry spans: both sides of a rename, otherwise just its path.
|
|
126
|
+
* A restore and a diff request both use this, so one selection covers the move.
|
|
127
|
+
*/
|
|
128
|
+
export function entryPaths(entry: ChangedPath): string[] {
|
|
129
|
+
return entry.change === "renamed" ? [entry.from, entry.path] : [entry.path];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The counts the plan and the summary report: `changed` is every path rewritten
|
|
134
|
+
* from the snapshot (modified, deleted, or a rename's source), and `removed` is
|
|
135
|
+
* the paths created after it, which the restore deletes. A rename counts once:
|
|
136
|
+
* it is one file changed, and its destination is removed as part of the move
|
|
137
|
+
* rather than counted a second time.
|
|
119
138
|
*/
|
|
120
139
|
export function summarize(paths: readonly ChangedPath[]): { changed: number; removed: number } {
|
|
121
140
|
let changed = 0;
|
|
@@ -126,3 +145,58 @@ export function summarize(paths: readonly ChangedPath[]): { changed: number; rem
|
|
|
126
145
|
}
|
|
127
146
|
return { changed, removed };
|
|
128
147
|
}
|
|
148
|
+
|
|
149
|
+
/** Line counts for one path, or nulls when git reports a binary change. */
|
|
150
|
+
export interface PathStat {
|
|
151
|
+
/** Added lines, or null when git reports `-` (binary). */
|
|
152
|
+
added: number | null;
|
|
153
|
+
/** Removed lines, or null when git reports `-` (binary). */
|
|
154
|
+
removed: number | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A changed path with the line counts the review list shows. */
|
|
158
|
+
export type ReviewEntry = ChangedPath & {
|
|
159
|
+
added: number | null;
|
|
160
|
+
removed: number | null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse `git diff --numstat` output into a path -> line-count map.
|
|
165
|
+
*
|
|
166
|
+
* Counts are `-` for a binary file, which becomes null so the screen can say
|
|
167
|
+
* `bin` instead of inventing a zero. A malformed line is skipped rather than
|
|
168
|
+
* guessed at, and the path is the rest of the line so a path containing a tab
|
|
169
|
+
* survives the split.
|
|
170
|
+
*/
|
|
171
|
+
export function parseNumStat(stdout: string): Map<string, PathStat> {
|
|
172
|
+
const stats = new Map<string, PathStat>();
|
|
173
|
+
for (const line of stdout.split("\n")) {
|
|
174
|
+
if (line.trim() === "") continue;
|
|
175
|
+
const [addedRaw, removedRaw, ...rest] = line.split("\t");
|
|
176
|
+
const path = rest.join("\t");
|
|
177
|
+
if (addedRaw === undefined || removedRaw === undefined || path === "") continue;
|
|
178
|
+
const added = addedRaw === "-" ? null : Number.parseInt(addedRaw, 10);
|
|
179
|
+
const removed = removedRaw === "-" ? null : Number.parseInt(removedRaw, 10);
|
|
180
|
+
if (added !== null && !Number.isFinite(added)) continue;
|
|
181
|
+
if (removed !== null && !Number.isFinite(removed)) continue;
|
|
182
|
+
stats.set(path, { added, removed });
|
|
183
|
+
}
|
|
184
|
+
return stats;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Attach line counts to changed paths and sort them for a stable screen order.
|
|
189
|
+
* Line counts are read with `--no-renames`, so a rename's `+n` lives on its
|
|
190
|
+
* destination and its `−n` on its source.
|
|
191
|
+
*/
|
|
192
|
+
export function toReviewEntries(paths: readonly ChangedPath[], stats: ReadonlyMap<string, PathStat>): ReviewEntry[] {
|
|
193
|
+
const countsOf = (entry: ChangedPath): PathStat => {
|
|
194
|
+
const destination = stats.get(entry.path);
|
|
195
|
+
const source = entry.change === "renamed" ? stats.get(entry.from) : undefined;
|
|
196
|
+
return {
|
|
197
|
+
added: destination?.added ?? null,
|
|
198
|
+
removed: (source ?? destination)?.removed ?? null,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
return [...paths].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({ ...entry, ...countsOf(entry) }));
|
|
202
|
+
}
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* pathspec, so the selected paths are checked out of the snapshot instead, and
|
|
12
12
|
* only the selected additions are deleted. Everything else is left exactly as it
|
|
13
13
|
* is, and the same temporary index is used, so the real index stays untouched
|
|
14
|
-
* either way.
|
|
14
|
+
* either way. A rename is one entry whose selection checks out its source and
|
|
15
|
+
* deletes its destination, so the pair cannot be split.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
import { lstatSync, rmSync } from "node:fs";
|
|
18
|
+
import { lstatSync, rmSync, statSync } from "node:fs";
|
|
18
19
|
import { resolve, sep } from "node:path";
|
|
19
20
|
import {
|
|
20
21
|
busyGitState,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
treeFromWorkingTree,
|
|
26
27
|
type RunGit,
|
|
27
28
|
} from "./git.ts";
|
|
28
|
-
import { summarize, type ChangedPath } from "./paths.ts";
|
|
29
|
+
import { entryPaths, summarize, type ChangedPath } from "./paths.ts";
|
|
29
30
|
import type { Snapshot, RestoreSummary } from "./types.ts";
|
|
30
31
|
|
|
31
32
|
export interface RestoreDeps {
|
|
@@ -52,6 +53,8 @@ export interface RestorePlan {
|
|
|
52
53
|
removed: number;
|
|
53
54
|
/** Every changed path, with how the restore treats it. */
|
|
54
55
|
paths: ChangedPath[];
|
|
56
|
+
/** Tree id of the staged working tree the comparison used. */
|
|
57
|
+
currentTree: string;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export type PlanResult = { ok: true; plan: RestorePlan } | { ok: false; reason: string };
|
|
@@ -65,12 +68,13 @@ export async function planRestore(deps: RestoreDeps, input: RestoreInput): Promi
|
|
|
65
68
|
const cur = await stagedTree(deps, input);
|
|
66
69
|
const paths = await changedPaths(deps, input, cur);
|
|
67
70
|
const diff = await diffStat(deps.runGit, input.root, input.target.commit, cur, selectionOf(paths));
|
|
68
|
-
return { ok: true, plan: { diff, ...summarize(paths), paths } };
|
|
71
|
+
return { ok: true, plan: { diff, ...summarize(paths), paths, currentTree: cur } };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** The selection narrowed to paths the plan actually found, or undefined for all. */
|
|
72
75
|
function selectionOf(paths: readonly ChangedPath[]): string[] | undefined {
|
|
73
|
-
|
|
76
|
+
const all = paths.flatMap(entryPaths);
|
|
77
|
+
return all.length > 0 ? all : undefined;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
@@ -82,12 +86,47 @@ async function stagedTree(deps: RestoreDeps, input: RestoreInput): Promise<strin
|
|
|
82
86
|
return treeFromWorkingTree(deps.runGit, input.root, input.indexFile, input.target.includeUntracked);
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Every changed path, narrowed to the caller's selection when one is given.
|
|
91
|
+
*
|
|
92
|
+
* The diff is never limited with a pathspec: git detects a rename only when both
|
|
93
|
+
* of its paths are in scope, so a narrowed diff would turn a selected rename
|
|
94
|
+
* back into an addition and lose the side that has to be recreated.
|
|
95
|
+
*/
|
|
86
96
|
async function changedPaths(deps: RestoreDeps, input: RestoreInput, cur: string): Promise<ChangedPath[]> {
|
|
87
97
|
const all = await nameStatus(deps.runGit, input.root, input.target.commit, cur);
|
|
88
98
|
if (!input.paths) return all;
|
|
89
99
|
const selected = new Set(input.paths);
|
|
90
|
-
return all.filter((entry) =>
|
|
100
|
+
return all.filter((entry) =>
|
|
101
|
+
entry.change === "renamed" ? selected.has(entry.path) || selected.has(entry.from) : selected.has(entry.path),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Paths checked out of the snapshot: everything except a path the snapshot lacks. */
|
|
106
|
+
function rewrittenPaths(paths: readonly ChangedPath[]): string[] {
|
|
107
|
+
return paths.flatMap((entry) => {
|
|
108
|
+
if (entry.change === "added") return [];
|
|
109
|
+
return entry.change === "renamed" ? [entry.from] : [entry.path];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether two working-tree paths name the same file.
|
|
115
|
+
*
|
|
116
|
+
* A case-only rename on a case-insensitive filesystem resolves both names to one
|
|
117
|
+
* file, so deleting the destination would delete the source the checkout just
|
|
118
|
+
* restored. Comparing device and inode is cross-platform and needs no assumption
|
|
119
|
+
* about the filesystem's case sensitivity.
|
|
120
|
+
*/
|
|
121
|
+
function sameFile(root: string, first: string, second: string): boolean {
|
|
122
|
+
try {
|
|
123
|
+
const a = statSync(resolve(root, first));
|
|
124
|
+
const b = statSync(resolve(root, second));
|
|
125
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
126
|
+
} catch {
|
|
127
|
+
// A missing path cannot be the same file, and deleting it is a no-op.
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
91
130
|
}
|
|
92
131
|
|
|
93
132
|
/** Delete a path under `root`, refusing to escape it or remove a directory. */
|
|
@@ -110,8 +149,7 @@ export async function applyRestore(deps: RestoreDeps, input: RestoreInput): Prom
|
|
|
110
149
|
const cur = await stagedTree(deps, input);
|
|
111
150
|
const paths = await changedPaths(deps, input, cur);
|
|
112
151
|
const { changed, removed } = summarize(paths);
|
|
113
|
-
const rewritten = paths
|
|
114
|
-
const created = paths.filter((entry) => entry.change === "added").map((entry) => entry.path);
|
|
152
|
+
const rewritten = rewrittenPaths(paths);
|
|
115
153
|
|
|
116
154
|
if (input.paths) {
|
|
117
155
|
await restorePaths(deps.runGit, input.root, input.target.commit, input.indexFile, rewritten);
|
|
@@ -120,7 +158,12 @@ export async function applyRestore(deps: RestoreDeps, input: RestoreInput): Prom
|
|
|
120
158
|
// which a pathspec checkout of every changed path would not.
|
|
121
159
|
await readTreeReset(deps.runGit, input.root, input.target.commit, input.indexFile);
|
|
122
160
|
}
|
|
123
|
-
for (const
|
|
161
|
+
for (const entry of paths) {
|
|
162
|
+
if (entry.change === "added") deleteFile(input.root, entry.path);
|
|
163
|
+
else if (entry.change === "renamed" && !sameFile(input.root, entry.from, entry.path)) {
|
|
164
|
+
deleteFile(input.root, entry.path);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
124
167
|
|
|
125
168
|
// A subset notice says "2 of 5 files", so it needs the whole diff's size; the
|
|
126
169
|
// all-path query is already in hand when nothing was narrowed.
|