@coderook/cli 0.29.4 → 0.29.5
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.
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "coderook",
|
|
3
3
|
"displayName": "CodeRook",
|
|
4
4
|
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
|
|
5
|
-
"version": "0.29.
|
|
5
|
+
"version": "0.29.5",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/api.js
CHANGED
|
@@ -18,6 +18,8 @@ exports.cancelMerge = cancelMerge;
|
|
|
18
18
|
exports.aiAccess = aiAccess;
|
|
19
19
|
exports.setAiAccess = setAiAccess;
|
|
20
20
|
exports.updateProject = updateProject;
|
|
21
|
+
exports.compareVersions = compareVersions;
|
|
22
|
+
exports.fileTextAt = fileTextAt;
|
|
21
23
|
exports.versions = versions;
|
|
22
24
|
exports.markRelease = markRelease;
|
|
23
25
|
exports.issues = issues;
|
|
@@ -313,7 +315,50 @@ async function updateProject(repositoryId, change) {
|
|
|
313
315
|
body: change,
|
|
314
316
|
});
|
|
315
317
|
}
|
|
316
|
-
/**
|
|
318
|
+
/**
|
|
319
|
+
* What changed between two saved versions.
|
|
320
|
+
*
|
|
321
|
+
* The service answers this from the stored trees, so an untouched folder deep
|
|
322
|
+
* in the project costs nothing: identical subtree ids short-circuit before
|
|
323
|
+
* either side is listed. That is why this asks the service rather than
|
|
324
|
+
* fetching both file lists and comparing them here — on a project of twenty
|
|
325
|
+
* thousand files the difference is one request against two very large ones.
|
|
326
|
+
*/
|
|
327
|
+
async function compareVersions(repositoryId, from, to) {
|
|
328
|
+
const body = await call(`/v1/repositories/${repositoryId}/compare` +
|
|
329
|
+
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
330
|
+
return body.changes ?? [];
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* One file's contents at one version, as text.
|
|
334
|
+
*
|
|
335
|
+
* Returns null for anything that is not text — a diff of a PNG is noise, and
|
|
336
|
+
* deciding that here keeps every caller from having to guess. The byte cap is
|
|
337
|
+
* the same reasoning: past it a file is data, and a line-by-line comparison of
|
|
338
|
+
* data is neither readable nor quick.
|
|
339
|
+
*/
|
|
340
|
+
async function fileTextAt(repositoryId, versionId, filePath, mostBytes = 2 * 1024 * 1024) {
|
|
341
|
+
const token = await (0, config_js_1.loadToken)();
|
|
342
|
+
if (!token)
|
|
343
|
+
throw new Error("Not signed in. Run: cbx sign-in");
|
|
344
|
+
const route = `${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
|
|
345
|
+
`/versions/${versionId}/file?path=${encodeURIComponent(filePath)}`;
|
|
346
|
+
const response = await fetch(route, {
|
|
347
|
+
headers: {
|
|
348
|
+
authorization: `Bearer ${token}`,
|
|
349
|
+
"user-agent": "CodeRook-CLI/0.1",
|
|
350
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
351
|
+
range: `bytes=0-${mostBytes - 1}`,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
if (!response.ok)
|
|
355
|
+
return null;
|
|
356
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
357
|
+
/* A NUL byte is the cheapest reliable sign that this is not text. */
|
|
358
|
+
if (bytes.includes(0))
|
|
359
|
+
return null;
|
|
360
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
361
|
+
}
|
|
317
362
|
async function versions(repositoryId) {
|
|
318
363
|
const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/versions`);
|
|
319
364
|
return (body.versions ?? []).map((row) => ({
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
|
40
40
|
const faults_js_1 = require("../../desktop-app/src/main/faults.js");
|
|
41
41
|
const version_commands_js_1 = require("./version_commands.js");
|
|
42
42
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
43
|
+
const diff_command_js_1 = require("./diff_command.js");
|
|
43
44
|
const profile_js_1 = require("../../desktop-app/src/main/profile.js");
|
|
44
45
|
const detect_js_1 = require("../../desktop-app/src/main/detect.js");
|
|
45
46
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
@@ -1722,6 +1723,30 @@ const SPECS = [
|
|
|
1722
1723
|
],
|
|
1723
1724
|
run: commandSubmit,
|
|
1724
1725
|
},
|
|
1726
|
+
{
|
|
1727
|
+
name: "diff",
|
|
1728
|
+
aliases: ["changes"],
|
|
1729
|
+
group: "Working with a folder",
|
|
1730
|
+
summary: "what changed, line by line",
|
|
1731
|
+
usage: "diff [from] [to]",
|
|
1732
|
+
detail: "With nothing after it, compares this folder against the version it is\n" +
|
|
1733
|
+
"on. With one version, compares this folder against that one. With two,\n" +
|
|
1734
|
+
"compares two saved versions — which the service answers from its own\n" +
|
|
1735
|
+
"stored trees, so an untouched corner of a large project costs nothing.\n\n" +
|
|
1736
|
+
"A version can be given as a number, as v12, or by the name it was\n" +
|
|
1737
|
+
"released under.",
|
|
1738
|
+
options: [
|
|
1739
|
+
{ flags: "--name-only", description: "list the files and not their lines" },
|
|
1740
|
+
{ flags: "--path <text>", description: "only paths containing this" },
|
|
1741
|
+
{
|
|
1742
|
+
flags: "--context <n>",
|
|
1743
|
+
description: "lines of context around a change (default 3)",
|
|
1744
|
+
},
|
|
1745
|
+
{ flags: "--project <name>", description: "which project" },
|
|
1746
|
+
],
|
|
1747
|
+
examples: ["cbx diff", "cbx diff 12", "cbx diff 11 12 --name-only"],
|
|
1748
|
+
run: diff_command_js_1.commandDiff,
|
|
1749
|
+
},
|
|
1725
1750
|
{
|
|
1726
1751
|
name: "unlink",
|
|
1727
1752
|
group: "Working with a folder",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.commandDiff = commandDiff;
|
|
7
|
+
/**
|
|
8
|
+
* What changed, from the terminal.
|
|
9
|
+
*
|
|
10
|
+
* This was the one central thing the command line could not do. The service
|
|
11
|
+
* has been able to compare two versions since the beginning — GET /compare
|
|
12
|
+
* answers it from the stored trees — and nothing called the route. Meanwhile
|
|
13
|
+
* the desktop application's diff shells out to `git diff`, so on a project
|
|
14
|
+
* that is not a git repository the feature is silently absent, which is an odd
|
|
15
|
+
* thing for a version host to depend on.
|
|
16
|
+
*
|
|
17
|
+
* So this reads the service's own answer and renders it. Two questions, which
|
|
18
|
+
* people mean at different times:
|
|
19
|
+
*
|
|
20
|
+
* cbx diff what have I changed since the version I am on
|
|
21
|
+
* cbx diff 12 the same, against version 12
|
|
22
|
+
* cbx diff 11 12 what changed between two saved versions
|
|
23
|
+
*/
|
|
24
|
+
const promises_1 = require("node:fs/promises");
|
|
25
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
26
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
27
|
+
const api_js_1 = require("./api.js");
|
|
28
|
+
const config_js_1 = require("./config.js");
|
|
29
|
+
const project_commands_js_1 = require("./project_commands.js");
|
|
30
|
+
const merge_diff_js_1 = require("../../desktop-app/src/shared/merge_diff.js");
|
|
31
|
+
const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
32
|
+
/*
|
|
33
|
+
Built rather than typed, so the escape character never sits literally in the
|
|
34
|
+
source. The other command files spell theirs out; this reads the same on a
|
|
35
|
+
terminal and survives being copied through a tool that eats control bytes.
|
|
36
|
+
*/
|
|
37
|
+
const ESC = String.fromCharCode(27);
|
|
38
|
+
const paint = (code) => (value) => `${ESC}[${code}m${value}${ESC}[0m`;
|
|
39
|
+
const dim = paint("2");
|
|
40
|
+
const bold = paint("1");
|
|
41
|
+
const red = paint("31");
|
|
42
|
+
const green = paint("32");
|
|
43
|
+
const accent = paint("33");
|
|
44
|
+
/*
|
|
45
|
+
The line comparison builds a table of one side's lines against the other's,
|
|
46
|
+
which is exact and quadratic. On a conflicted file — what it was written for
|
|
47
|
+
— that is a few hundred kilobytes. On this repository's own eighteen-thousand
|
|
48
|
+
line runtime it would be gigabytes, so past this a file is reported as
|
|
49
|
+
changed without its lines. Saying so beats both wedging the terminal and
|
|
50
|
+
printing a heuristic diff that quietly invents a change.
|
|
51
|
+
*/
|
|
52
|
+
const MOST_LINES_COMPARED = 4_000;
|
|
53
|
+
/** How a change reads at a glance. */
|
|
54
|
+
const MARK = {
|
|
55
|
+
added: green("+"),
|
|
56
|
+
removed: red("-"),
|
|
57
|
+
changed: accent("~"),
|
|
58
|
+
};
|
|
59
|
+
/** Print one file's lines, folded to the interesting parts. */
|
|
60
|
+
function printHunks(before, after, context) {
|
|
61
|
+
const left = (0, merge_diff_js_1.linesOf)(before);
|
|
62
|
+
const right = (0, merge_diff_js_1.linesOf)(after);
|
|
63
|
+
if (left.length > MOST_LINES_COMPARED || right.length > MOST_LINES_COMPARED) {
|
|
64
|
+
console.log(dim(` ${Math.max(left.length, right.length).toLocaleString()} lines — ` +
|
|
65
|
+
`too large to compare line by line`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const chunk of (0, merge_diff_js_1.foldUnchanged)((0, merge_diff_js_1.diffLines)(before, after), context)) {
|
|
69
|
+
if (chunk.kind === "gap") {
|
|
70
|
+
console.log(dim(` ... ${chunk.skipped} unchanged`));
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const line of chunk.lines) {
|
|
74
|
+
const at = String(line.candidateLine ?? line.targetLine ?? "").padStart(5);
|
|
75
|
+
if (line.side === "same")
|
|
76
|
+
console.log(dim(`${at} ${line.text}`));
|
|
77
|
+
else if (line.side === "candidate")
|
|
78
|
+
console.log(`${dim(at)} ${green("+")} ${green(line.text)}`);
|
|
79
|
+
else
|
|
80
|
+
console.log(`${dim(at)} ${red("-")} ${red(line.text)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Resolve `12`, `v12` or a version's name against the newest-first list. */
|
|
85
|
+
function versionBy(list, reference) {
|
|
86
|
+
const bare = reference.replace(/^v/i, "");
|
|
87
|
+
if (/^[0-9]+$/.test(bare)) {
|
|
88
|
+
const found = list.find((one) => one.sequence === Number(bare));
|
|
89
|
+
return found ? { id: found.id, sequence: found.sequence } : null;
|
|
90
|
+
}
|
|
91
|
+
const named = list.find((one) => (one.name ?? "").toLowerCase() === reference.toLowerCase());
|
|
92
|
+
return named ? { id: named.id, sequence: named.sequence } : null;
|
|
93
|
+
}
|
|
94
|
+
/** Whether these bytes are something a line comparison can speak about. */
|
|
95
|
+
function isText(value) {
|
|
96
|
+
return !value.includes(String.fromCharCode(0));
|
|
97
|
+
}
|
|
98
|
+
async function commandDiff(parsed) {
|
|
99
|
+
const nameOnly = parsed.flags.has("name-only");
|
|
100
|
+
const context = Number(parsed.flags.get("context") ?? 3) || 3;
|
|
101
|
+
const only = parsed.flags.get("path");
|
|
102
|
+
const named = parsed.flags.get("project");
|
|
103
|
+
const project = await (0, project_commands_js_1.resolveProject)(typeof named === "string" ? named : undefined);
|
|
104
|
+
if (!project)
|
|
105
|
+
return 1;
|
|
106
|
+
const list = await (0, api_js_1.versions)(project.id);
|
|
107
|
+
if (!list.length) {
|
|
108
|
+
console.log("Nothing saved yet, so there is nothing to compare.");
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
const [first, second] = parsed.positional;
|
|
112
|
+
/*
|
|
113
|
+
Two saved versions, compared by the service. That is the whole point of
|
|
114
|
+
asking it rather than fetching both file lists: it walks the stored trees
|
|
115
|
+
and skips any subtree whose id has not moved, so an untouched corner of a
|
|
116
|
+
twenty-thousand-file project costs nothing.
|
|
117
|
+
*/
|
|
118
|
+
if (first && second) {
|
|
119
|
+
const from = versionBy(list, first);
|
|
120
|
+
const to = versionBy(list, second);
|
|
121
|
+
if (!from || !to) {
|
|
122
|
+
console.error(red(`No such version: ${from ? second : first}`));
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
const changes = (await (0, api_js_1.compareVersions)(project.id, from.id, to.id))
|
|
126
|
+
.filter((change) => !only || change.path.includes(String(only)))
|
|
127
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
128
|
+
console.log(`${bold(project.slug)} ${dim(`v${from.sequence} -> v${to.sequence}`)}`);
|
|
129
|
+
if (!changes.length) {
|
|
130
|
+
console.log("Nothing differs between them.");
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
for (const change of changes) {
|
|
134
|
+
console.log(`${MARK[change.kind]} ${change.path}`);
|
|
135
|
+
if (nameOnly || change.kind !== "changed")
|
|
136
|
+
continue;
|
|
137
|
+
const [before, after] = await Promise.all([
|
|
138
|
+
(0, api_js_1.fileTextAt)(project.id, from.id, change.path),
|
|
139
|
+
(0, api_js_1.fileTextAt)(project.id, to.id, change.path),
|
|
140
|
+
]);
|
|
141
|
+
if (before === null || after === null) {
|
|
142
|
+
console.log(dim(" not text"));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
printHunks(before, after, context);
|
|
146
|
+
}
|
|
147
|
+
const many = changes.length === 1 ? "" : "s";
|
|
148
|
+
console.log(dim(`\n${changes.length} file${many} differ${many ? "" : "s"}`));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
/*
|
|
152
|
+
This folder against a saved version — the question somebody has open in
|
|
153
|
+
front of them, and the reason `cbx status` was never enough on its own: it
|
|
154
|
+
says which files changed and never what changed inside them.
|
|
155
|
+
*/
|
|
156
|
+
const link = await (0, config_js_1.readLink)(node_process_1.default.cwd());
|
|
157
|
+
if (!link) {
|
|
158
|
+
console.error(red("This folder is not linked to a project, so there is no local side."));
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
const against = first
|
|
162
|
+
? versionBy(list, first)
|
|
163
|
+
: { id: link.baseVersionId ?? list[0].id, sequence: link.sequence };
|
|
164
|
+
if (!against) {
|
|
165
|
+
console.error(red(`No such version: ${first}`));
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
const folder = node_process_1.default.cwd();
|
|
169
|
+
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
170
|
+
/*
|
|
171
|
+
Which files differ is decided here, from digests, before anything is
|
|
172
|
+
fetched.
|
|
173
|
+
|
|
174
|
+
The first version of this compared contents, which meant downloading every
|
|
175
|
+
file in the project to find out which ones had moved — on this repository
|
|
176
|
+
that is eleven hundred requests to report a handful of edits. `changedFiles`
|
|
177
|
+
already answers the question locally and keeps a digest cache while it does
|
|
178
|
+
it, so the service is asked only for the files that actually differ.
|
|
179
|
+
*/
|
|
180
|
+
const saved = link.manifest ?? {};
|
|
181
|
+
const changed = (await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(saved)), "synchronize"))
|
|
182
|
+
.filter((one) => !only || one.path.includes(String(only)))
|
|
183
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
184
|
+
console.log(`${bold(node_path_1.default.basename(folder))} ${dim(`this folder -> v${against.sequence}`)}`);
|
|
185
|
+
let differing = 0;
|
|
186
|
+
for (const one of changed) {
|
|
187
|
+
const wasSaved = Object.prototype.hasOwnProperty.call(saved, one.path);
|
|
188
|
+
const kind = one.deleted
|
|
189
|
+
? "removed"
|
|
190
|
+
: wasSaved
|
|
191
|
+
? "changed"
|
|
192
|
+
: "added";
|
|
193
|
+
differing += 1;
|
|
194
|
+
console.log(`${MARK[kind]} ${one.path}`);
|
|
195
|
+
if (nameOnly || kind !== "changed")
|
|
196
|
+
continue;
|
|
197
|
+
if (one.binary) {
|
|
198
|
+
console.log(dim(" not text"));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const [before, after] = await Promise.all([
|
|
202
|
+
(0, api_js_1.fileTextAt)(project.id, against.id, one.path),
|
|
203
|
+
(0, promises_1.readFile)(node_path_1.default.join(folder, one.path), "utf8").catch(() => null),
|
|
204
|
+
]);
|
|
205
|
+
if (before === null || after === null || !isText(after)) {
|
|
206
|
+
console.log(dim(" not text"));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
printHunks(before, after, context);
|
|
210
|
+
}
|
|
211
|
+
if (!differing) {
|
|
212
|
+
console.log("This folder matches the saved version.");
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
const many = differing === 1 ? "" : "s";
|
|
216
|
+
console.log(dim(`\n${differing} file${many} differ${many ? "" : "s"}`));
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.STARTER_TOPUP_HEADING = exports.SUGGESTED_HEADING = void 0;
|
|
6
7
|
exports.suggestExclusions = suggestExclusions;
|
|
7
8
|
exports.suggestBulkDirectories = suggestBulkDirectories;
|
|
8
9
|
exports.rulesFromSuggestions = rulesFromSuggestions;
|
|
@@ -267,12 +268,19 @@ function suggestBulkDirectories(files) {
|
|
|
267
268
|
* looking: the detector proposes, this records a decision, and the two are
|
|
268
269
|
* only connected by somebody choosing.
|
|
269
270
|
*/
|
|
271
|
+
/**
|
|
272
|
+
* The one heading under which accepted suggestions are recorded.
|
|
273
|
+
*
|
|
274
|
+
* Exported because three places wrote three different sentences for the same
|
|
275
|
+
* idea, and a .gitignore could end up carrying all of them. The tick screen
|
|
276
|
+
* keeps its own CHOSEN_HEADING: that block is a different statement, and it is
|
|
277
|
+
* rewritten wholesale where this one is added to.
|
|
278
|
+
*/
|
|
279
|
+
exports.SUGGESTED_HEADING = "# Suggested for this folder, and accepted.";
|
|
280
|
+
/** And the one under which the starter rules are brought up to date. */
|
|
281
|
+
exports.STARTER_TOPUP_HEADING = "# Added when the starter rules were brought up to date.";
|
|
270
282
|
function rulesFromSuggestions(accepted) {
|
|
271
283
|
if (!accepted.length)
|
|
272
284
|
return "";
|
|
273
|
-
return [
|
|
274
|
-
"# Suggested when this folder was added, and accepted.",
|
|
275
|
-
...accepted.map((one) => one.pattern),
|
|
276
|
-
"",
|
|
277
|
-
].join("\n");
|
|
285
|
+
return [exports.SUGGESTED_HEADING, ...accepted.map((one) => one.pattern), ""].join("\n");
|
|
278
286
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Showing what two people actually did to the same file.
|
|
4
|
+
*
|
|
5
|
+
* The merge screen used to offer four buttons — keep yours, keep theirs, keep
|
|
6
|
+
* both, remove it — above nothing at all. You were asked which side wins for
|
|
7
|
+
* a file you could not look at, which is not a decision so much as a guess,
|
|
8
|
+
* and the safe guess ("keep both") leaves the project holding two copies of
|
|
9
|
+
* everything anybody ever disagreed about.
|
|
10
|
+
*
|
|
11
|
+
* So the two sides are laid out line by line. Nothing here is a merge
|
|
12
|
+
* algorithm: the service already tried that and put the file on a Track
|
|
13
|
+
* precisely because the automatic answer was wrong. This is for reading.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.linesOf = linesOf;
|
|
17
|
+
exports.diffLines = diffLines;
|
|
18
|
+
exports.foldUnchanged = foldUnchanged;
|
|
19
|
+
exports.conflictDraft = conflictDraft;
|
|
20
|
+
exports.hasConflictMarkers = hasConflictMarkers;
|
|
21
|
+
/**
|
|
22
|
+
* The lines two texts share, longest run first.
|
|
23
|
+
*
|
|
24
|
+
* A plain longest-common-subsequence table. Both sides of a conflicted file
|
|
25
|
+
* are one file's worth of lines, so the quadratic table is a few hundred
|
|
26
|
+
* kilobytes at worst — and the alternative, a heuristic diff, sometimes
|
|
27
|
+
* reports a change where there is none, which on this screen would mean
|
|
28
|
+
* showing somebody a disagreement that does not exist.
|
|
29
|
+
*/
|
|
30
|
+
function commonRuns(left, right) {
|
|
31
|
+
const table = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
|
|
32
|
+
for (let a = left.length - 1; a >= 0; a -= 1) {
|
|
33
|
+
for (let b = right.length - 1; b >= 0; b -= 1) {
|
|
34
|
+
table[a][b] =
|
|
35
|
+
left[a] === right[b]
|
|
36
|
+
? table[a + 1][b + 1] + 1
|
|
37
|
+
: Math.max(table[a + 1][b], table[a][b + 1]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return table;
|
|
41
|
+
}
|
|
42
|
+
/** Split into lines without inventing a trailing empty one. */
|
|
43
|
+
function linesOf(text) {
|
|
44
|
+
if (text === "")
|
|
45
|
+
return [];
|
|
46
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
47
|
+
if (lines.length > 1 && lines[lines.length - 1] === "")
|
|
48
|
+
lines.pop();
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* How the saved file and the uploaded file differ, in order.
|
|
53
|
+
*
|
|
54
|
+
* `target` is what the project already held and `candidate` is what was
|
|
55
|
+
* uploaded, named the way the service names them rather than "old" and "new"
|
|
56
|
+
* — neither side is older, which is the whole reason there is a decision to
|
|
57
|
+
* make.
|
|
58
|
+
*/
|
|
59
|
+
function diffLines(target, candidate) {
|
|
60
|
+
const left = linesOf(target);
|
|
61
|
+
const right = linesOf(candidate);
|
|
62
|
+
const table = commonRuns(left, right);
|
|
63
|
+
const out = [];
|
|
64
|
+
let a = 0;
|
|
65
|
+
let b = 0;
|
|
66
|
+
while (a < left.length && b < right.length) {
|
|
67
|
+
if (left[a] === right[b]) {
|
|
68
|
+
out.push({
|
|
69
|
+
side: "same",
|
|
70
|
+
targetLine: a + 1,
|
|
71
|
+
candidateLine: b + 1,
|
|
72
|
+
text: left[a],
|
|
73
|
+
});
|
|
74
|
+
a += 1;
|
|
75
|
+
b += 1;
|
|
76
|
+
}
|
|
77
|
+
else if (table[a + 1][b] >= table[a][b + 1]) {
|
|
78
|
+
out.push({ side: "target", targetLine: a + 1, candidateLine: null, text: left[a] });
|
|
79
|
+
a += 1;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
out.push({
|
|
83
|
+
side: "candidate",
|
|
84
|
+
targetLine: null,
|
|
85
|
+
candidateLine: b + 1,
|
|
86
|
+
text: right[b],
|
|
87
|
+
});
|
|
88
|
+
b += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
while (a < left.length) {
|
|
92
|
+
out.push({ side: "target", targetLine: a + 1, candidateLine: null, text: left[a] });
|
|
93
|
+
a += 1;
|
|
94
|
+
}
|
|
95
|
+
while (b < right.length) {
|
|
96
|
+
out.push({
|
|
97
|
+
side: "candidate",
|
|
98
|
+
targetLine: null,
|
|
99
|
+
candidateLine: b + 1,
|
|
100
|
+
text: right[b],
|
|
101
|
+
});
|
|
102
|
+
b += 1;
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function foldUnchanged(lines, context = 3) {
|
|
107
|
+
const interesting = new Set();
|
|
108
|
+
lines.forEach((line, index) => {
|
|
109
|
+
if (line.side === "same")
|
|
110
|
+
return;
|
|
111
|
+
for (let at = Math.max(0, index - context); at <= Math.min(lines.length - 1, index + context); at += 1) {
|
|
112
|
+
interesting.add(at);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
/*
|
|
116
|
+
A file with no differences at all is still worth showing — a conflict can
|
|
117
|
+
be about two people creating the same path with identical content, or
|
|
118
|
+
about a file one side deleted, and folding the whole thing away would
|
|
119
|
+
leave the screen empty with no explanation.
|
|
120
|
+
*/
|
|
121
|
+
if (!interesting.size)
|
|
122
|
+
return [{ kind: "lines", lines }];
|
|
123
|
+
const chunks = [];
|
|
124
|
+
let run = [];
|
|
125
|
+
let skipped = 0;
|
|
126
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
127
|
+
if (interesting.has(index)) {
|
|
128
|
+
if (skipped) {
|
|
129
|
+
chunks.push({ kind: "gap", skipped });
|
|
130
|
+
skipped = 0;
|
|
131
|
+
}
|
|
132
|
+
run.push(lines[index]);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
if (run.length) {
|
|
136
|
+
chunks.push({ kind: "lines", lines: run });
|
|
137
|
+
run = [];
|
|
138
|
+
}
|
|
139
|
+
skipped += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (run.length)
|
|
143
|
+
chunks.push({ kind: "lines", lines: run });
|
|
144
|
+
if (skipped)
|
|
145
|
+
chunks.push({ kind: "gap", skipped });
|
|
146
|
+
return chunks;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A starting point for somebody who wants to edit rather than choose.
|
|
150
|
+
*
|
|
151
|
+
* Both sides, marked the way every merge tool has marked them since RCS, so
|
|
152
|
+
* the text is immediately familiar and every editor already highlights it.
|
|
153
|
+
* Agreed lines appear once; the rest are stacked with the markers between
|
|
154
|
+
* them, and it is the person's job to delete the markers along with whatever
|
|
155
|
+
* they decided against.
|
|
156
|
+
*/
|
|
157
|
+
function conflictDraft(target, candidate, labels = {
|
|
158
|
+
target: "saved on the project",
|
|
159
|
+
candidate: "your upload",
|
|
160
|
+
}) {
|
|
161
|
+
const out = [];
|
|
162
|
+
let held = { target: [], candidate: [] };
|
|
163
|
+
const flush = () => {
|
|
164
|
+
if (!held.target.length && !held.candidate.length)
|
|
165
|
+
return;
|
|
166
|
+
out.push(`<<<<<<< ${labels.target}`);
|
|
167
|
+
out.push(...held.target);
|
|
168
|
+
out.push("=======");
|
|
169
|
+
out.push(...held.candidate);
|
|
170
|
+
out.push(`>>>>>>> ${labels.candidate}`);
|
|
171
|
+
held = { target: [], candidate: [] };
|
|
172
|
+
};
|
|
173
|
+
for (const line of diffLines(target, candidate)) {
|
|
174
|
+
if (line.side === "same") {
|
|
175
|
+
flush();
|
|
176
|
+
out.push(line.text);
|
|
177
|
+
}
|
|
178
|
+
else if (line.side === "target") {
|
|
179
|
+
held.target.push(line.text);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
held.candidate.push(line.text);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
flush();
|
|
186
|
+
return out.join("\n");
|
|
187
|
+
}
|
|
188
|
+
/** Whether any conflict marker is still in the text somebody is about to send. */
|
|
189
|
+
function hasConflictMarkers(text) {
|
|
190
|
+
return /^(?:<{7}|={7}|>{7})(?:\s|$)/m.test(text);
|
|
191
|
+
}
|