@tanishqkancharla/diffmap 0.1.4 → 0.2.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 +69 -61
- package/index.html +18 -0
- package/package.json +7 -5
- package/src/annotations.ts +36 -29
- package/src/changes.ts +229 -0
- package/src/cli.test.ts +50 -0
- package/src/cli.ts +41 -5
- package/src/errors.ts +10 -0
- package/src/gist/GistApp.tsx +39 -20
- package/src/gist/GistStatus.tsx +2 -51
- package/src/gist/fetchGist.ts +23 -0
- package/src/git.test.ts +402 -0
- package/src/git.ts +498 -0
- package/src/github/GitHubApp.tsx +39 -22
- package/src/github/fetchRepo.test.ts +59 -1
- package/src/github/fetchRepo.ts +99 -43
- package/src/github/patch.test.ts +18 -9
- package/src/github/patch.ts +30 -19
- package/src/host/HostHome.tsx +86 -0
- package/src/host/files.ts +10 -0
- package/src/host/home.md +156 -0
- package/src/host/home.test.ts +83 -0
- package/src/index.ts +1 -0
- package/src/packageVersion.ts +7 -0
- package/src/parseFence.ts +6 -6
- package/src/parseViewer.test.ts +87 -0
- package/src/parseViewer.ts +103 -77
- package/src/serve.test.ts +306 -0
- package/src/serve.ts +48 -5
- package/src/share.ts +18 -0
- package/src/update.test.ts +19 -0
- package/src/update.ts +285 -0
- package/src/viewer/CallStackDiff.tsx +15 -3
- package/src/viewer/ChangesCard.tsx +267 -0
- package/src/viewer/CloseServerButton.tsx +16 -0
- package/src/viewer/ComarkView.tsx +83 -15
- package/src/viewer/Fence.tsx +9 -1
- package/src/viewer/FileExcerpt.tsx +25 -3
- package/src/viewer/HtmlPlaceholder.tsx +6 -1
- package/src/viewer/MermaidBlock.tsx +99 -11
- package/src/viewer/RangePicker.tsx +526 -0
- package/src/viewer/SourceDiffPanel.tsx +277 -121
- package/src/viewer/TableOfContents.tsx +313 -39
- package/src/viewer/ViewerApp.tsx +120 -68
- package/src/viewer/changesSource.ts +91 -0
- package/src/viewer/sourceHeader.test.ts +2 -87
- package/src/viewer/sourceHeader.ts +4 -63
- package/src/viewer/sourceNavigation.ts +33 -42
- package/src/viewer/viewerMode.ts +1 -1
- package/vite.config.ts +2 -1
- package/vite.gist.config.ts +57 -0
- package/src/viewer/Check.tsx +0 -24
- package/src/viewer/DoneButton.tsx +0 -32
package/src/update.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { DiffmapUpdateError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
// The unscoped `diffmap` package on npm is unrelated; always use the scoped name.
|
|
6
|
+
export const packageName = "@tanishqkancharla/diffmap";
|
|
7
|
+
const latestSpec = `${packageName}@latest`;
|
|
8
|
+
const registryUrl =
|
|
9
|
+
"https://registry.npmjs.org/@tanishqkancharla%2Fdiffmap/latest";
|
|
10
|
+
|
|
11
|
+
type InstallMethod = "npm" | "pnpm" | "bun" | "npx" | "unknown";
|
|
12
|
+
|
|
13
|
+
type Command = { command: string; args: string[] };
|
|
14
|
+
|
|
15
|
+
type UpdateResult =
|
|
16
|
+
| { status: "up-to-date"; current: string; latest: string; message: string }
|
|
17
|
+
| {
|
|
18
|
+
status: "updated";
|
|
19
|
+
previous: string;
|
|
20
|
+
current: string;
|
|
21
|
+
command: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
status: "manual";
|
|
26
|
+
current: string;
|
|
27
|
+
latest: string;
|
|
28
|
+
command: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type UpdateDeps = {
|
|
33
|
+
currentVersion: string;
|
|
34
|
+
fetchLatestVersion: () => Promise<string | Error>;
|
|
35
|
+
detectInstallMethod: () => Promise<InstallMethod>;
|
|
36
|
+
runCommand: (command: Command) => Promise<void | Error>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Routes `diffmap --update [...]` to the `update` command so incur's built-in updater never runs. */
|
|
40
|
+
export function resolveUpdateArgv(argv: string[]) {
|
|
41
|
+
const separator = argv.indexOf("--");
|
|
42
|
+
const flags = separator === -1 ? argv : argv.slice(0, separator);
|
|
43
|
+
if (!flags.includes("--update")) return argv;
|
|
44
|
+
const rest = argv.filter((token, index) => {
|
|
45
|
+
return token !== "--update" || (separator !== -1 && index > separator);
|
|
46
|
+
});
|
|
47
|
+
return ["update", ...rest];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ParsedVersion = {
|
|
51
|
+
core: [number, number, number];
|
|
52
|
+
prerelease: string[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function parseVersion(version: string): ParsedVersion | undefined {
|
|
56
|
+
const match = version
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/^v/, "")
|
|
59
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
60
|
+
if (!match) return undefined;
|
|
61
|
+
return {
|
|
62
|
+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
63
|
+
prerelease: match[4] === undefined ? [] : match[4].split("."),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Semver precedence: negative when `a < b`, positive when `a > b`, 0 when equal. */
|
|
68
|
+
export function compareVersions(a: string, b: string) {
|
|
69
|
+
const left = parseVersion(a);
|
|
70
|
+
const right = parseVersion(b);
|
|
71
|
+
if (!left || !right) {
|
|
72
|
+
return new DiffmapUpdateError({
|
|
73
|
+
reason: `cannot compare versions "${a}" and "${b}"`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (let index = 0; index < 3; index++) {
|
|
77
|
+
const difference = left.core[index]! - right.core[index]!;
|
|
78
|
+
if (difference !== 0) return Math.sign(difference);
|
|
79
|
+
}
|
|
80
|
+
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
|
81
|
+
return Math.sign(right.prerelease.length - left.prerelease.length);
|
|
82
|
+
}
|
|
83
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
84
|
+
for (let index = 0; index < length; index++) {
|
|
85
|
+
const leftPart = left.prerelease[index];
|
|
86
|
+
const rightPart = right.prerelease[index];
|
|
87
|
+
if (leftPart === undefined) return -1;
|
|
88
|
+
if (rightPart === undefined) return 1;
|
|
89
|
+
if (leftPart === rightPart) continue;
|
|
90
|
+
const leftNumeric = /^\d+$/.test(leftPart);
|
|
91
|
+
const rightNumeric = /^\d+$/.test(rightPart);
|
|
92
|
+
if (leftNumeric && rightNumeric) {
|
|
93
|
+
return Math.sign(Number(leftPart) - Number(rightPart));
|
|
94
|
+
}
|
|
95
|
+
if (leftNumeric) return -1;
|
|
96
|
+
if (rightNumeric) return 1;
|
|
97
|
+
return leftPart < rightPart ? -1 : 1;
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizePath(value: string) {
|
|
103
|
+
return value.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Infers the install method from the real path of the running entry script. */
|
|
107
|
+
function detectInstallMethodFromPath(input: {
|
|
108
|
+
entryPath: string;
|
|
109
|
+
env: Record<string, string | undefined>;
|
|
110
|
+
npmGlobalRoot?: string | undefined;
|
|
111
|
+
}): InstallMethod {
|
|
112
|
+
const entry = normalizePath(input.entryPath);
|
|
113
|
+
if (input.env.npm_command === "exec" || entry.includes("/_npx/")) {
|
|
114
|
+
return "npx";
|
|
115
|
+
}
|
|
116
|
+
if (entry.includes("/.bun/install/global/")) return "bun";
|
|
117
|
+
const pnpmHome = input.env.PNPM_HOME;
|
|
118
|
+
if (
|
|
119
|
+
entry.includes("/pnpm/global/") ||
|
|
120
|
+
(pnpmHome !== undefined &&
|
|
121
|
+
pnpmHome !== "" &&
|
|
122
|
+
entry.startsWith(`${normalizePath(pnpmHome)}/global/`))
|
|
123
|
+
) {
|
|
124
|
+
return "pnpm";
|
|
125
|
+
}
|
|
126
|
+
if (input.npmGlobalRoot !== undefined && input.npmGlobalRoot !== "") {
|
|
127
|
+
const root = normalizePath(input.npmGlobalRoot);
|
|
128
|
+
if (entry.startsWith(`${root}/${packageName}/`)) return "npm";
|
|
129
|
+
}
|
|
130
|
+
return "unknown";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function globalInstallCommand(method: "npm" | "pnpm" | "bun"): Command {
|
|
134
|
+
if (method === "pnpm") {
|
|
135
|
+
return { command: "pnpm", args: ["add", "--global", latestSpec] };
|
|
136
|
+
}
|
|
137
|
+
if (method === "bun") {
|
|
138
|
+
return { command: "bun", args: ["add", "--global", latestSpec] };
|
|
139
|
+
}
|
|
140
|
+
return { command: "npm", args: ["install", "--global", latestSpec] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatCommand(command: Command) {
|
|
144
|
+
return [command.command, ...command.args].join(" ");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function runUpdate(deps: UpdateDeps) {
|
|
148
|
+
const current = deps.currentVersion;
|
|
149
|
+
const latest = await deps.fetchLatestVersion();
|
|
150
|
+
if (latest instanceof Error) return latest;
|
|
151
|
+
|
|
152
|
+
const comparison = compareVersions(current, latest);
|
|
153
|
+
if (comparison instanceof Error) return comparison;
|
|
154
|
+
if (comparison >= 0) {
|
|
155
|
+
return {
|
|
156
|
+
message: `diffmap is already up to date (${current})`,
|
|
157
|
+
status: "up-to-date",
|
|
158
|
+
current,
|
|
159
|
+
latest,
|
|
160
|
+
} satisfies UpdateResult;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const method = await deps.detectInstallMethod();
|
|
164
|
+
if (method === "npx") {
|
|
165
|
+
const command = `npx ${latestSpec}`;
|
|
166
|
+
return {
|
|
167
|
+
message: `diffmap ${latest} is available (running ${current} through npx). Run \`${command}\` to use it, or install it globally with \`${formatCommand(globalInstallCommand("npm"))}\`.`,
|
|
168
|
+
status: "manual",
|
|
169
|
+
current,
|
|
170
|
+
latest,
|
|
171
|
+
command,
|
|
172
|
+
} satisfies UpdateResult;
|
|
173
|
+
}
|
|
174
|
+
if (method === "unknown") {
|
|
175
|
+
const command = formatCommand(globalInstallCommand("npm"));
|
|
176
|
+
return {
|
|
177
|
+
message: `diffmap ${latest} is available (running ${current}), but diffmap could not tell how it was installed. Update it with \`${command}\` or your package manager's equivalent.`,
|
|
178
|
+
status: "manual",
|
|
179
|
+
current,
|
|
180
|
+
latest,
|
|
181
|
+
command,
|
|
182
|
+
} satisfies UpdateResult;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const install = globalInstallCommand(method);
|
|
186
|
+
const installed = await deps.runCommand(install);
|
|
187
|
+
if (installed instanceof Error) return installed;
|
|
188
|
+
return {
|
|
189
|
+
message: `Updated diffmap from ${current} to ${latest}`,
|
|
190
|
+
status: "updated",
|
|
191
|
+
previous: current,
|
|
192
|
+
current: latest,
|
|
193
|
+
command: formatCommand(install),
|
|
194
|
+
} satisfies UpdateResult;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function fetchLatestVersion() {
|
|
198
|
+
const response = await fetch(registryUrl, {
|
|
199
|
+
headers: { accept: "application/json" },
|
|
200
|
+
signal: AbortSignal.timeout(10_000),
|
|
201
|
+
}).catch(
|
|
202
|
+
(cause) =>
|
|
203
|
+
new DiffmapUpdateError({
|
|
204
|
+
reason: "could not reach the npm registry",
|
|
205
|
+
cause,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
if (response instanceof Error) return response;
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
return new DiffmapUpdateError({
|
|
211
|
+
reason: `npm registry responded ${response.status} for ${packageName}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const body = (await response.json().catch(() => undefined)) as
|
|
215
|
+
| { version?: unknown }
|
|
216
|
+
| undefined;
|
|
217
|
+
if (typeof body?.version !== "string") {
|
|
218
|
+
return new DiffmapUpdateError({
|
|
219
|
+
reason: `npm registry returned no version for ${packageName}`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return body.version;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function execCommand(command: Command) {
|
|
226
|
+
return new Promise<{ stdout: string; stderr: string } | Error>((resolve) => {
|
|
227
|
+
execFile(
|
|
228
|
+
command.command,
|
|
229
|
+
command.args,
|
|
230
|
+
{ shell: process.platform === "win32", maxBuffer: 10_000_000 },
|
|
231
|
+
(cause, stdout, stderr) => {
|
|
232
|
+
if (cause) {
|
|
233
|
+
const detail = stderr.trim() || stdout.trim() || cause.message;
|
|
234
|
+
resolve(
|
|
235
|
+
new DiffmapUpdateError({
|
|
236
|
+
reason: `\`${formatCommand(command)}\` failed: ${detail}`,
|
|
237
|
+
cause,
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
resolve({ stdout, stderr });
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readNpmGlobalRoot() {
|
|
249
|
+
const result = await execCommand({
|
|
250
|
+
command: "npm",
|
|
251
|
+
args: ["root", "--global"],
|
|
252
|
+
});
|
|
253
|
+
if (result instanceof Error) return undefined;
|
|
254
|
+
const root = result.stdout.trim();
|
|
255
|
+
if (root === "") return undefined;
|
|
256
|
+
try {
|
|
257
|
+
return fs.realpathSync(root);
|
|
258
|
+
} catch {
|
|
259
|
+
return root;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function detectInstallMethod() {
|
|
264
|
+
const entry = process.argv[1];
|
|
265
|
+
if (entry === undefined) return "unknown" satisfies InstallMethod;
|
|
266
|
+
let entryPath = entry;
|
|
267
|
+
try {
|
|
268
|
+
entryPath = fs.realpathSync(entry);
|
|
269
|
+
} catch {}
|
|
270
|
+
const withoutNpm = detectInstallMethodFromPath({
|
|
271
|
+
entryPath,
|
|
272
|
+
env: process.env,
|
|
273
|
+
});
|
|
274
|
+
if (withoutNpm !== "unknown") return withoutNpm;
|
|
275
|
+
return detectInstallMethodFromPath({
|
|
276
|
+
entryPath,
|
|
277
|
+
env: process.env,
|
|
278
|
+
npmGlobalRoot: await readNpmGlobalRoot(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function runCommand(command: Command) {
|
|
283
|
+
const result = await execCommand(command);
|
|
284
|
+
if (result instanceof Error) return result;
|
|
285
|
+
}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
backgroundColor,
|
|
3
|
+
colors,
|
|
4
|
+
focusRing,
|
|
5
|
+
LARGE_SCALE,
|
|
6
|
+
spacing,
|
|
7
|
+
text,
|
|
8
|
+
} from "maui";
|
|
9
|
+
import { defineVars, style, useStyles } from "purse-styles";
|
|
3
10
|
import { codeFontFamily } from "../codeFont.js";
|
|
4
11
|
import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
|
|
5
12
|
import { pierreShell } from "./pierre.js";
|
|
@@ -80,6 +87,10 @@ export function CallStackDiff(
|
|
|
80
87
|
);
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
const rowSize = defineVars({
|
|
91
|
+
height: { default: "28px", [LARGE_SCALE]: "40px" },
|
|
92
|
+
});
|
|
93
|
+
|
|
83
94
|
const styles = {
|
|
84
95
|
shell: style(
|
|
85
96
|
text({ size: "sm", fontWeight: 500, color: "highContrast" }),
|
|
@@ -120,10 +131,11 @@ const styles = {
|
|
|
120
131
|
alignItems: "stretch",
|
|
121
132
|
flex: "1 1 auto",
|
|
122
133
|
minWidth: 0,
|
|
123
|
-
minHeight:
|
|
134
|
+
minHeight: rowSize.height,
|
|
124
135
|
overflow: "hidden",
|
|
125
136
|
},
|
|
126
137
|
"& .stack-label": {
|
|
138
|
+
alignSelf: "center",
|
|
127
139
|
flex: "1 1 auto",
|
|
128
140
|
minWidth: 0,
|
|
129
141
|
paddingBlock: "3px",
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import {
|
|
2
|
+
backgroundColor,
|
|
3
|
+
borderColor,
|
|
4
|
+
colors,
|
|
5
|
+
Css,
|
|
6
|
+
File,
|
|
7
|
+
FileCode,
|
|
8
|
+
FileText,
|
|
9
|
+
focusRing,
|
|
10
|
+
GitBranch,
|
|
11
|
+
text,
|
|
12
|
+
} from "maui";
|
|
13
|
+
import { style, useStyles } from "purse-styles";
|
|
14
|
+
import type { SourceAnnotation, SourceNavigation } from "../annotations.js";
|
|
15
|
+
import {
|
|
16
|
+
isCommitRev,
|
|
17
|
+
shortRev,
|
|
18
|
+
WORKING,
|
|
19
|
+
type ChangedFile,
|
|
20
|
+
type Changes,
|
|
21
|
+
type Rev,
|
|
22
|
+
} from "../changes.js";
|
|
23
|
+
import { codeFontFamily } from "../codeFont.js";
|
|
24
|
+
import { useChanges } from "./changesSource.ts";
|
|
25
|
+
import { pierreShell } from "./pierre.js";
|
|
26
|
+
|
|
27
|
+
/** A phase's file summary. Review opens every file; a row focuses one. */
|
|
28
|
+
export function ChangesCard(props: { rev: Rev } & SourceNavigation) {
|
|
29
|
+
const changes = useChanges(props.rev);
|
|
30
|
+
const shell = useStyles(pierreShell, styles.shell);
|
|
31
|
+
const header = useStyles(styles.header);
|
|
32
|
+
const row = useStyles(styles.row);
|
|
33
|
+
const review = useStyles(styles.review);
|
|
34
|
+
const muted = useStyles(styles.muted);
|
|
35
|
+
const range = useStyles(styles.range);
|
|
36
|
+
const open = (annotation: SourceAnnotation) =>
|
|
37
|
+
props.onSelectAnnotation(annotation);
|
|
38
|
+
const files =
|
|
39
|
+
changes === undefined || changes instanceof Error ? [] : changes.files;
|
|
40
|
+
return (
|
|
41
|
+
<div className={shell} data-diffmap-kind="changes">
|
|
42
|
+
<div className={header}>
|
|
43
|
+
<GitBranch size="sm" />
|
|
44
|
+
<span className={range}>{revLabel(props.rev, changes)}</span>
|
|
45
|
+
{changes === undefined || changes instanceof Error ? (
|
|
46
|
+
<span className={muted}>
|
|
47
|
+
{changes === undefined ? "Loading changes…" : changes.message}
|
|
48
|
+
</span>
|
|
49
|
+
) : (
|
|
50
|
+
<ChangesSummary changes={changes} />
|
|
51
|
+
)}
|
|
52
|
+
{files.length > 0 && (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className={review}
|
|
56
|
+
aria-controls="source-diff-panel"
|
|
57
|
+
onClick={() =>
|
|
58
|
+
open({
|
|
59
|
+
text: "Review",
|
|
60
|
+
references: [{ kind: "changes", rev: props.rev }],
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
Review
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
{files.map((file) => {
|
|
69
|
+
const annotation: SourceAnnotation = {
|
|
70
|
+
text: file.path,
|
|
71
|
+
references: [
|
|
72
|
+
props.rev === WORKING
|
|
73
|
+
? { kind: "file", path: file.path }
|
|
74
|
+
: { kind: "file", rev: props.rev, path: file.path },
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
return (
|
|
78
|
+
<button
|
|
79
|
+
key={file.path}
|
|
80
|
+
type="button"
|
|
81
|
+
className={row}
|
|
82
|
+
title={file.path}
|
|
83
|
+
aria-controls="source-diff-panel"
|
|
84
|
+
aria-pressed={
|
|
85
|
+
props.selectedAnnotation?.text === file.path &&
|
|
86
|
+
props.selectedAnnotation.references[0]?.kind === "file" &&
|
|
87
|
+
props.selectedAnnotation.references[0].rev ===
|
|
88
|
+
annotation.references[0]!.rev
|
|
89
|
+
}
|
|
90
|
+
onClick={() => open(annotation)}
|
|
91
|
+
>
|
|
92
|
+
<FileIcon path={file.path} />
|
|
93
|
+
<span className="name">
|
|
94
|
+
{file.path.split("/").pop()}
|
|
95
|
+
{file.status === "deleted" && (
|
|
96
|
+
<span className={muted}> deleted</span>
|
|
97
|
+
)}
|
|
98
|
+
</span>
|
|
99
|
+
<DiffStat file={file} />
|
|
100
|
+
</button>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** "9 files changed +214 −119" for a whole rev; the card and the Diff panel header share it. */
|
|
108
|
+
export function ChangesSummary(props: {
|
|
109
|
+
changes: Changes;
|
|
110
|
+
className?: string;
|
|
111
|
+
}) {
|
|
112
|
+
const summary = useStyles(styles.summary);
|
|
113
|
+
const { files, truncated } = props.changes;
|
|
114
|
+
const additions = files.reduce((sum, file) => sum + file.additions, 0);
|
|
115
|
+
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
|
|
116
|
+
const count = files.length;
|
|
117
|
+
return (
|
|
118
|
+
<span className={`${summary} ${props.className ?? ""}`}>
|
|
119
|
+
<span
|
|
120
|
+
title={
|
|
121
|
+
truncated === true
|
|
122
|
+
? "GitHub listed only these files. Open the commit on GitHub for the rest."
|
|
123
|
+
: undefined
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
{count}
|
|
127
|
+
{truncated === true && "+"} {count === 1 ? "file" : "files"} changed
|
|
128
|
+
</span>
|
|
129
|
+
{additions > 0 && <span className="added">+{additions}</span>}
|
|
130
|
+
{deletions > 0 && <span className="removed">−{deletions}</span>}
|
|
131
|
+
</span>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Labels the rev as the range picker does: a commit by its title, a range as `<from>…<to>`. */
|
|
136
|
+
function revLabel(rev: Rev, changes: Changes | Error | undefined) {
|
|
137
|
+
const single = isCommitRev(rev) && !rev.includes("..");
|
|
138
|
+
if (single && changes !== undefined && !(changes instanceof Error))
|
|
139
|
+
return changes.title;
|
|
140
|
+
return shortRev(rev).replace("..", "…");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function DiffStat(props: { file: ChangedFile }) {
|
|
144
|
+
if (props.file.binary) return <span className="stat">binary</span>;
|
|
145
|
+
return (
|
|
146
|
+
<span className="stat">
|
|
147
|
+
{props.file.additions > 0 && (
|
|
148
|
+
<span className="added">+{props.file.additions}</span>
|
|
149
|
+
)}
|
|
150
|
+
{props.file.deletions > 0 && (
|
|
151
|
+
<span className="removed">−{props.file.deletions}</span>
|
|
152
|
+
)}
|
|
153
|
+
</span>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function FileIcon(props: { path: string }) {
|
|
158
|
+
const extension = props.path.split(".").pop()?.toLowerCase() ?? "";
|
|
159
|
+
if (/^(css|scss|less)$/.test(extension)) return <Css size="sm" />;
|
|
160
|
+
if (/^(md|mdx|txt|rst)$/.test(extension)) return <FileText size="sm" />;
|
|
161
|
+
if (
|
|
162
|
+
/^(c|cc|cpp|cs|go|h|java|jsx?|kt|mjs|cjs|py|rb|rs|sh|swift|tsx?|vue|svelte|json|ya?ml|toml|html)$/.test(
|
|
163
|
+
extension,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return <FileCode size="sm" />;
|
|
167
|
+
return <File size="sm" />;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Left inset of the file rows' icons. */
|
|
171
|
+
const ROW_INSET = 6;
|
|
172
|
+
/** Review's box inset from the card's top and right edges: (36px header − 24px button) / 2. */
|
|
173
|
+
const EDGE = 6;
|
|
174
|
+
/** Right inset of the stats, which Review's text also ends at. */
|
|
175
|
+
const STAT_INSET = 12;
|
|
176
|
+
|
|
177
|
+
const styles = {
|
|
178
|
+
shell: style(text({ size: "sm", color: "highContrast" }), {
|
|
179
|
+
backgroundColor: backgroundColor.app,
|
|
180
|
+
maxWidth: "100%",
|
|
181
|
+
}),
|
|
182
|
+
// Same insets and gap as the file rows, so the branch icon lines up with the file icons
|
|
183
|
+
// and the range with the names. Review's box sits EDGE from the top and right; its
|
|
184
|
+
// text lines up with the stats.
|
|
185
|
+
header: style({
|
|
186
|
+
display: "flex",
|
|
187
|
+
alignItems: "center",
|
|
188
|
+
gap: "10px",
|
|
189
|
+
minHeight: "36px",
|
|
190
|
+
paddingLeft: `${String(ROW_INSET)}px`,
|
|
191
|
+
paddingRight: `${String(EDGE)}px`,
|
|
192
|
+
"& > svg": { flexShrink: 0, color: colors.gray[11] },
|
|
193
|
+
// Divides the summary from the file rows; a card with no files has no divider.
|
|
194
|
+
"&:not(:last-child)": { borderBottom: `1px solid ${borderColor.border}` },
|
|
195
|
+
}),
|
|
196
|
+
range: style(text({ size: "sm", fontWeight: 500, color: "highContrast" }), {
|
|
197
|
+
flex: "1 1 auto",
|
|
198
|
+
minWidth: 0,
|
|
199
|
+
overflow: "hidden",
|
|
200
|
+
textOverflow: "ellipsis",
|
|
201
|
+
whiteSpace: "nowrap",
|
|
202
|
+
}),
|
|
203
|
+
summary: style(text({ size: "sm", color: "lowContrast" }), {
|
|
204
|
+
display: "flex",
|
|
205
|
+
gap: "8px",
|
|
206
|
+
flexShrink: 0,
|
|
207
|
+
whiteSpace: "nowrap",
|
|
208
|
+
fontVariantNumeric: "tabular-nums",
|
|
209
|
+
"& .added, & .removed": { fontFamily: codeFontFamily },
|
|
210
|
+
"& .added": { color: colors.green[11] },
|
|
211
|
+
"& .removed": { color: colors.red[11] },
|
|
212
|
+
}),
|
|
213
|
+
review: style(
|
|
214
|
+
text({ size: "sm", fontWeight: 400, color: "highContrast" }),
|
|
215
|
+
{
|
|
216
|
+
height: "24px",
|
|
217
|
+
padding: `0 ${String(STAT_INSET - EDGE)}px`,
|
|
218
|
+
border: 0,
|
|
219
|
+
borderRadius: "4px",
|
|
220
|
+
background: "transparent",
|
|
221
|
+
cursor: "pointer",
|
|
222
|
+
"&:hover": { backgroundColor: colors.gray[3] },
|
|
223
|
+
},
|
|
224
|
+
focusRing(),
|
|
225
|
+
),
|
|
226
|
+
row: style(
|
|
227
|
+
{
|
|
228
|
+
paddingLeft: `${String(ROW_INSET)}px`,
|
|
229
|
+
paddingRight: `${String(STAT_INSET)}px`,
|
|
230
|
+
display: "flex",
|
|
231
|
+
alignItems: "center",
|
|
232
|
+
gap: "10px",
|
|
233
|
+
width: "100%",
|
|
234
|
+
minHeight: "32px",
|
|
235
|
+
font: "inherit",
|
|
236
|
+
textAlign: "left",
|
|
237
|
+
color: "inherit",
|
|
238
|
+
background: "transparent",
|
|
239
|
+
border: 0,
|
|
240
|
+
cursor: "pointer",
|
|
241
|
+
"& > svg": { flexShrink: 0, color: colors.gray[11] },
|
|
242
|
+
"& .name": {
|
|
243
|
+
flex: "1 1 auto",
|
|
244
|
+
minWidth: 0,
|
|
245
|
+
overflow: "hidden",
|
|
246
|
+
textOverflow: "ellipsis",
|
|
247
|
+
whiteSpace: "nowrap",
|
|
248
|
+
},
|
|
249
|
+
"& .stat": {
|
|
250
|
+
display: "flex",
|
|
251
|
+
gap: "8px",
|
|
252
|
+
flexShrink: 0,
|
|
253
|
+
fontFamily: codeFontFamily,
|
|
254
|
+
fontVariantNumeric: "tabular-nums",
|
|
255
|
+
color: colors.gray[11],
|
|
256
|
+
},
|
|
257
|
+
"& .added": { color: colors.green[11] },
|
|
258
|
+
"& .removed": { color: colors.red[11] },
|
|
259
|
+
"&:hover": { backgroundColor: colors.gray[3] },
|
|
260
|
+
"&[aria-pressed='true'], &[aria-pressed='true']:hover": {
|
|
261
|
+
backgroundColor: colors.blue[3],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
focusRing(),
|
|
265
|
+
),
|
|
266
|
+
muted: style({ color: colors.gray[11] }),
|
|
267
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Button, Close, colors } from "maui";
|
|
2
|
+
import { style, useStyles } from "purse-styles";
|
|
3
|
+
|
|
4
|
+
const closeIcon = style({
|
|
5
|
+
color: colors.red[9],
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export function CloseServerButton(props: { onClick?: () => void }) {
|
|
9
|
+
const iconClass = useStyles(closeIcon);
|
|
10
|
+
return (
|
|
11
|
+
<Button variant="default" onClick={props.onClick}>
|
|
12
|
+
<Close className={iconClass} size="sm" />
|
|
13
|
+
Close server
|
|
14
|
+
</Button>
|
|
15
|
+
);
|
|
16
|
+
}
|