bed495a9 Andy McCall 1 month ago ↶
1
// Activation: find oxy, find repositories, wire the source control view,
2
// decorations, the content provider, and the commands (spec §3).
3
4
import * as vscode from "vscode";
1f7fc1e3 Andy McCall 1 month ago ↶
5
import { OxygenAPI, RemoteProvider, RemoteTarget, RepositoryHandle, linkedLines, pickProvider, repositoryFor as rootFor, stateSpec } from "./api";
44bfd5ad Andy McCall 1 month ago ↶
6
import { basename, join } from "node:path";
b4626290 Andy McCall 1 month ago ↶
7
import { Oxy, OxyError, ShowInfo, findBinary } from "./oxy";
d2c9a584 Andy McCall 1 month ago ↶
8
import { Repository, OxyResourceState, SubrepoState } from "./repository";
9
import { BisectProvider, TrustProvider } from "./panels";
ab81294a Andy McCall 1 month ago ↶
10
import { OxyContentProvider, SCHEME, baseUri, oxyUri } from "./contentProvider";
bed495a9 Andy McCall 1 month ago ↶
11
import { OxyDecorationProvider } from "./decorations";
ab81294a Andy McCall 1 month ago ↶
12
import { OperationItem, OperationsProvider, timeAgo } from "./operations";
13
import { BlameController } from "./blame";
14
import { HistoryPanel } from "./history";
44bfd5ad Andy McCall 1 month ago ↶
15
import { MessageHistory, conflictMarkerLines, placeholder, signingLabel } from "./commitbox";
16
import { readFile, stat } from "node:fs/promises";
25103ddd Andy McCall 1 month ago ↶
17
import { ConflictCodeLensProvider, acceptSides, keepCandidate, markResolved } from "./conflicts";
19639205 Andy McCall 1 month ago ↶
18
import { parsePlanQuery, parseResolveQuery, sameRemote } from "./handoff";
bed495a9 Andy McCall 1 month ago ↶
19
20
const MIN_VERSION = { format: 2, protocol: 1 };
21
22
let output: vscode.OutputChannel;
23
let oxy: Oxy;
24
let contentProvider: OxyContentProvider;
25
let decorations: OxyDecorationProvider;
35d56b58 Andy McCall 1 month ago ↶
26
let operations: OperationsProvider;
ab81294a Andy McCall 1 month ago ↶
27
let blame: BlameController;
25103ddd Andy McCall 1 month ago ↶
28
let conflictLenses: ConflictCodeLensProvider;
d2c9a584 Andy McCall 1 month ago ↶
29
let bisectView: BisectProvider;
30
let trustView: TrustProvider;
bed495a9 Andy McCall 1 month ago ↶
31
const repositories = new Map<string, Repository>();
32
f6bda77e Andy McCall 1 month ago ↶
33
export async function activate(context: vscode.ExtensionContext): Promise<OxygenAPI | undefined> {
44bfd5ad Andy McCall 1 month ago ↶
34
extensionContext = context;
bed495a9 Andy McCall 1 month ago ↶
35
output = vscode.window.createOutputChannel("Oxygen");
36
context.subscriptions.push(output);
810a1c53 Andy McCall 1 month ago ↶
37
output.appendLine(`Oxygen extension ${context.extension.packageJSON.version} activating; folders: ${(vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath).join(", ") || "(none)"}`);
bed495a9 Andy McCall 1 month ago ↶
38
oxy = new Oxy(findBinary(vscode.workspace.getConfiguration("oxygen").get<string>("path")), (line) => output.appendLine(line));
39
40
try {
41
const v = await oxy.version();
42
output.appendLine(`oxy ${v.version} (format ${v.format}, protocol ${v.protocol}) at ${oxy.binary}`);
43
if (v.format < MIN_VERSION.format || v.protocol < MIN_VERSION.protocol) {
44
void vscode.window.showWarningMessage(`Oxygen: oxy ${v.version} is older than this extension supports.`);
45
}
46
} catch (e) {
47
const pick = await vscode.window.showErrorMessage(`Oxygen: could not run oxy (${(e as Error).message}). Set "oxygen.path" or put oxy on your PATH.`, "Open Settings");
48
if (pick === "Open Settings") {
49
void vscode.commands.executeCommand("workbench.action.openSettings", "oxygen.path");
50
}
51
return;
52
}
53
54
contentProvider = new OxyContentProvider(oxy);
55
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(SCHEME, contentProvider));
56
decorations = new OxyDecorationProvider(() => [...repositories.values()]);
57
context.subscriptions.push(decorations);
35d56b58 Andy McCall 1 month ago ↶
58
operations = new OperationsProvider(() => [...repositories.values()]);
59
context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.operations", operations));
ab81294a Andy McCall 1 month ago ↶
60
blame = new BlameController(() => [...repositories.values()]);
61
context.subscriptions.push(blame);
25103ddd Andy McCall 1 month ago ↶
62
conflictLenses = new ConflictCodeLensProvider(() => [...repositories.values()]);
63
context.subscriptions.push(vscode.languages.registerCodeLensProvider({ scheme: "file" }, conflictLenses));
d2c9a584 Andy McCall 1 month ago ↶
64
bisectView = new BisectProvider(() => [...repositories.values()]);
65
trustView = new TrustProvider(() => [...repositories.values()]);
66
context.subscriptions.push(vscode.window.registerTreeDataProvider("oxygen.bisect", bisectView), vscode.window.registerTreeDataProvider("oxygen.trust", trustView));
bed495a9 Andy McCall 1 month ago ↶
67
68
context.subscriptions.push(
69
vscode.commands.registerCommand("oxygen.refresh", async () => {
70
await Promise.all([...repositories.values()].map((r) => r.refresh()));
71
}),
72
vscode.commands.registerCommand("oxygen.showOutput", () => output.show()),
1f7fc1e3 Andy McCall 1 month ago ↶
73
vscode.commands.registerCommand("oxygen.openOnRemote", (arg?: Repository | vscode.SourceControl | LinkArg) =>
74
openOnRemote(undefined, arg instanceof Repository ? arg : undefined, "open", arg instanceof Repository || isSourceControl(arg) ? undefined : (arg as LinkArg)),
75
),
76
vscode.commands.registerCommand("oxygen.copyRemoteLink", (arg?: LinkArg) => openOnRemote(undefined, undefined, "copy", arg)),
77
vscode.commands.registerCommand("oxygen.copyPermalink", (arg?: LinkArg) => openOnRemote(undefined, undefined, "permalink", arg)),
f6bda77e Andy McCall 1 month ago ↶
78
vscode.commands.registerCommand("oxygen.openChangeOnRemote", (root: string, hash: string) => openOnRemote({ kind: "change", hash }, repositories.get(root))),
bed495a9 Andy McCall 1 month ago ↶
79
vscode.commands.registerCommand("oxygen.openFile", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
80
vscode.commands.registerCommand("oxygen.openChanges", (state?: OxyResourceState) => state && openChanges(state)),
81
vscode.commands.registerCommand("oxygen.openBaseVersion", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", baseUri(state.repository.root, state.path))),
82
vscode.commands.registerCommand("oxygen.commit", (arg?: Repository | vscode.SourceControl) => commit(arg)),
83
vscode.commands.registerCommand("oxygen.showLog", () => showLog()),
810a1c53 Andy McCall 1 month ago ↶
84
vscode.commands.registerCommand("oxygen.init", () => initRepository()),
35d56b58 Andy McCall 1 month ago ↶
85
vscode.commands.registerCommand("oxygen.discard", (state?: OxyResourceState, states?: OxyResourceState[]) => discard(states?.length ? states : state ? [state] : [])),
86
vscode.commands.registerCommand("oxygen.commitSelected", (state?: OxyResourceState, states?: OxyResourceState[]) => commitSelected(states?.length ? states : state ? [state] : [])),
87
vscode.commands.registerCommand("oxygen.commitHunks", (arg?: Repository | vscode.SourceControl) => commitHunks(arg)),
88
vscode.commands.registerCommand("oxygen.amend", (arg?: Repository | vscode.SourceControl) => amend(arg)),
b4626290 Andy McCall 1 month ago ↶
89
vscode.commands.registerCommand("oxygen.history.split", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "split")),
90
vscode.commands.registerCommand("oxygen.history.squash", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "squash")),
91
vscode.commands.registerCommand("oxygen.history.rebase", (ctx?: { root?: string; hash?: string }) => historyContext(ctx, "rebase")),
44bfd5ad Andy McCall 1 month ago ↶
92
vscode.commands.registerCommand("oxygen.amendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, true)),
93
vscode.commands.registerCommand("oxygen.leaveAmendMode", (arg?: Repository | vscode.SourceControl) => toggleAmend(arg, false)),
94
vscode.commands.registerCommand("oxygen.messageHistory", (arg?: Repository | vscode.SourceControl) => messageHistory(arg)),
35d56b58 Andy McCall 1 month ago ↶
95
vscode.commands.registerCommand("oxygen.undo", (arg?: Repository | vscode.SourceControl) => undo(arg, 1)),
96
vscode.commands.registerCommand("oxygen.undoTo", (item?: OperationItem) => item && undo(item.repository, item.stepsBack)),
97
vscode.commands.registerCommand("oxygen.refreshOperations", () => operations.refresh()),
c8f95589 Andy McCall 1 month ago ↶
98
vscode.commands.registerCommand("oxygen.switchBookmark", (arg?: Repository | vscode.SourceControl) => switchBookmark(arg)),
99
vscode.commands.registerCommand("oxygen.createBookmark", (arg?: Repository | vscode.SourceControl) => createBookmark(arg)),
100
vscode.commands.registerCommand("oxygen.mergeBookmark", (arg?: Repository | vscode.SourceControl) => mergeBookmark(arg)),
101
vscode.commands.registerCommand("oxygen.deleteBookmark", (arg?: Repository | vscode.SourceControl) => deleteBookmark(arg)),
102
vscode.commands.registerCommand("oxygen.fetch", (arg?: Repository | vscode.SourceControl) => fetch(arg)),
103
vscode.commands.registerCommand("oxygen.pull", (arg?: Repository | vscode.SourceControl) => pull(arg)),
104
vscode.commands.registerCommand("oxygen.push", (arg?: Repository | vscode.SourceControl) => push(arg, false)),
105
vscode.commands.registerCommand("oxygen.pushBookmark", (arg?: Repository | vscode.SourceControl) => push(arg, true)),
106
vscode.commands.registerCommand("oxygen.sync", (arg?: Repository | vscode.SourceControl) => sync(arg)),
ab81294a Andy McCall 1 month ago ↶
107
vscode.commands.registerCommand("oxygen.showHistory", (arg?: Repository | vscode.SourceControl) => {
108
const repo = repositoryFor(arg);
109
if (repo) {
110
HistoryPanel.show(repo, (hash) => void showChange(repo.root, hash));
111
}
112
}),
113
vscode.commands.registerCommand("oxygen.fileHistory", (uri?: vscode.Uri) => fileHistory(uri ?? vscode.window.activeTextEditor?.document.uri)),
114
vscode.commands.registerCommand("oxygen.toggleBlame", () => blame.toggle()),
115
vscode.commands.registerCommand("oxygen.showChange", (root?: string, hash?: string) => root && hash && showChange(root, hash)),
25103ddd Andy McCall 1 month ago ↶
116
vscode.commands.registerCommand("oxygen.acceptSides", (uri: vscode.Uri, index: number, keep: number[]) => acceptSides(uri, index, keep).catch(showError)),
117
vscode.commands.registerCommand("oxygen.keepCandidate", (state?: OxyResourceState) => state && keepCandidate(state.repository, state.path).catch(showError)),
118
vscode.commands.registerCommand("oxygen.markResolved", (arg?: Repository | vscode.SourceControl) => {
119
const repo = repositoryFor(arg);
120
return repo && markResolved(repo).catch(showError);
121
}),
122
vscode.commands.registerCommand("oxygen.openConflict", (state?: OxyResourceState) => state && vscode.commands.executeCommand("vscode.open", state.resourceUri)),
d2c9a584 Andy McCall 1 month ago ↶
123
vscode.commands.registerCommand("oxygen.openSubrepo", (state?: SubrepoState) => state && openSubrepo(state)),
124
vscode.commands.registerCommand("oxygen.updateSubrepo", (state?: SubrepoState) => state && updateSubrepos(state.repository, true, [state.info.path])),
125
vscode.commands.registerCommand("oxygen.updateSubrepos", (arg?: Repository | vscode.SourceControl) => {
126
const repo = repositoryFor(arg);
127
return repo && updateSubrepos(repo, true, []);
128
}),
129
vscode.commands.registerCommand("oxygen.bisectStart", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectStart(r.root).then(() => "Started; mark a good and a bad change"))),
130
vscode.commands.registerCommand("oxygen.bisectGood", (arg?: Repository, changes?: string[]) => bisectMark(arg, "good", changes)),
131
vscode.commands.registerCommand("oxygen.bisectBad", (arg?: Repository, changes?: string[]) => bisectMark(arg, "bad", changes)),
132
vscode.commands.registerCommand("oxygen.bisectSkip", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectMark(r.root, "skip"))),
133
vscode.commands.registerCommand("oxygen.bisectRun", (arg?: Repository) => bisectRun(arg)),
134
vscode.commands.registerCommand("oxygen.bisectReset", (arg?: Repository) => bisect(arg, (r) => r.oxy.bisectReset(r.root).then(() => "Bisect reset; working copy restored"))),
135
vscode.commands.registerCommand("oxygen.refreshBisect", () => bisectView.refresh()),
136
vscode.commands.registerCommand("oxygen.refreshTrust", () => trustView.refresh()),
137
vscode.commands.registerCommand("oxygen.verify", (arg?: Repository | vscode.SourceControl) => verify(arg)),
138
vscode.commands.registerCommand("oxygen.tags", (arg?: Repository | vscode.SourceControl) => tags(arg)),
139
vscode.commands.registerCommand("oxygen.createTag", (arg?: Repository | vscode.SourceControl) => createTag(arg)),
140
vscode.commands.registerCommand("oxygen.addWorkspace", (arg?: Repository | vscode.SourceControl) => addWorkspace(arg)),
141
vscode.commands.registerCommand("oxygen.clone", () => cloneRepository()),
013fefef Andy McCall 1 month ago ↶
142
vscode.commands.registerCommand("oxygen.createIdentity", () => createIdentity()),
f532c426 Andy McCall 1 month ago ↶
143
vscode.window.registerUriHandler({ handleUri: (uri) => handleUri(uri) }),
bed495a9 Andy McCall 1 month ago ↶
144
vscode.workspace.onDidChangeWorkspaceFolders(() => void discover()),
145
vscode.workspace.onDidChangeConfiguration((e) => {
146
if (e.affectsConfiguration("oxygen.path")) {
147
void vscode.window.showInformationMessage("Oxygen: reload the window to use the new oxy path.");
148
}
149
if (e.affectsConfiguration("oxygen")) {
150
decorations.refresh();
151
for (const r of repositories.values()) {
152
r.scheduleRefresh();
153
}
154
}
155
}),
156
);
157
context.subscriptions.push({ dispose: () => repositories.forEach((r) => r.dispose()) });
158
159
await discover();
f6bda77e Andy McCall 1 month ago ↶
160
return buildApi();
161
}
162
163
164
// ---------------------------------------------------------------- exported API
165
166
const remoteProviders: RemoteProvider[] = [];
167
const onDidChangeRepositoriesEmitter = new vscode.EventEmitter<void>();
168
const onDidChangeStatusEmitter = new vscode.EventEmitter<RepositoryHandle>();
169
170
function handleOf(repo: Repository): RepositoryHandle {
171
return {
172
root: repo.root,
173
get status() {
174
return repo.status;
175
},
176
get bookmarks() {
177
return repo.bookmarks.map((b) => ({ name: b.name, heads: b.heads, current: b.current }));
178
},
179
get currentBookmark() {
180
return repo.currentBookmark?.name;
181
},
0c26846a Andy McCall 1 month ago ↶
182
remotes: async () => (await repo.oxy.remotes(repo.root)).map((r) => ({ name: r.name, url: r.url, ca: r.ca ?? undefined })),
f6bda77e Andy McCall 1 month ago ↶
183
json: <T>(args: string[]) => repo.oxy.json<T>(repo.root, args),
184
refresh: () => repo.refresh(),
185
};
186
}
187
188
function buildApi(): OxygenAPI {
189
return {
190
version: 1,
191
get repositories() {
192
return [...repositories.values()].map(handleOf);
193
},
194
onDidChangeStatus: (listener) => onDidChangeStatusEmitter.event(listener),
195
onDidChangeRepositories: (listener) => onDidChangeRepositoriesEmitter.event(listener),
196
getRepository: (fsPath) => {
197
const root = rootFor([...repositories.keys()], fsPath);
198
const repo = root ? repositories.get(root) : undefined;
199
return repo ? handleOf(repo) : undefined;
200
},
201
registerRemoteProvider: (provider) => {
202
remoteProviders.push(provider);
203
void vscode.commands.executeCommand("setContext", "oxygen.hasRemoteProvider", true);
204
return {
205
dispose: () => {
206
const i = remoteProviders.indexOf(provider);
207
if (i >= 0) {
208
remoteProviders.splice(i, 1);
209
}
210
void vscode.commands.executeCommand("setContext", "oxygen.hasRemoteProvider", remoteProviders.length > 0);
211
},
212
};
213
},
214
remoteProviders: () => [...remoteProviders],
215
};
216
}
1f7fc1e3 Andy McCall 1 month ago ↶
217
218
/** What a remote-link command does with the URL. */
219
type LinkMode = "open" | "copy" | "permalink";
f6bda77e Andy McCall 1 month ago ↶
220
1f7fc1e3 Andy McCall 1 month ago ↶
221
/** The editor gutter passes `{ lineNumber, uri }`; an editor title menu passes the file's Uri. */
222
type LinkArg = { lineNumber?: number; uri?: vscode.Uri } | vscode.Uri | undefined;
223
224
/**
225
* Open or copy a link to the current repository, file or change on
226
* whichever provider knows its remote. For a file, the lines are the
227
* gutter's clicked line or the selection; `permalink` pins the link to the
228
* bookmark's current state (its heads), so it keeps pointing at these lines.
229
*/
230
async function openOnRemote(target?: RemoteTarget, repoArg?: Repository, mode: LinkMode = "open", arg?: LinkArg): Promise<void> {
231
const argUri = arg instanceof vscode.Uri ? arg : arg?.uri;
232
const clicked = arg && !(arg instanceof vscode.Uri) ? arg.lineNumber : undefined;
f6bda77e Andy McCall 1 month ago ↶
233
const editor = vscode.window.activeTextEditor;
1f7fc1e3 Andy McCall 1 month ago ↶
234
const docUri = argUri ?? editor?.document.uri;
235
const repo = repoArg ?? (docUri ? repositoryFor2(docUri) : undefined) ?? [...repositories.values()][0];
f6bda77e Andy McCall 1 month ago ↶
236
if (!repo) {
237
void vscode.window.showInformationMessage("Oxygen: no repository is open.");
238
return;
239
}
240
const remotes = (await repo.oxy.remotes(repo.root)).map((r) => ({ name: r.name, url: r.url }));
241
const pick = pickProvider(remoteProviders, remotes);
242
if (!pick) {
9627171e Andy McCall 1 month ago ↶
243
void vscode.window.showInformationMessage("Oxygen: no extension knows this repository's remotes. Install a forge extension such as OxygenHub.");
f6bda77e Andy McCall 1 month ago ↶
244
return;
245
}
246
let t: RemoteTarget = target ?? { kind: "repository" };
1f7fc1e3 Andy McCall 1 month ago ↶
247
let note = "";
248
if (!target && docUri) {
249
const rel = repo.relativePath(docUri);
f6bda77e Andy McCall 1 month ago ↶
250
if (rel) {
1f7fc1e3 Andy McCall 1 month ago ↶
251
const bookmark = repo.currentBookmark;
252
let reference = bookmark?.name ?? "main";
253
if (mode === "permalink") {
254
const spec = stateSpec(bookmark?.heads ?? []);
255
if (!spec) {
256
void vscode.window.showInformationMessage("Oxygen: this bookmark has no committed state to link to yet.");
257
return;
258
}
259
reference = spec;
260
}
261
const sameDoc = editor && editor.document.uri.toString() === docUri.toString();
262
const sel = sameDoc
263
? { start: editor.selection.start.line + 1, end: editor.selection.end.line + 1, endColumn: editor.selection.end.character, empty: editor.selection.isEmpty }
264
: { start: clicked ?? 1, end: clicked ?? 1, endColumn: 0, empty: true };
265
const lines = clicked !== undefined || sameDoc ? linkedLines(clicked, sel) : {};
266
t = { kind: "file", reference, path: rel, ...lines };
267
// Lines are counted in the editor; say so when they may not match the remote.
268
const edited = repo.status?.paths.some((p) => p.path === rel);
269
if (edited || (sameDoc && editor.document.isDirty)) {
270
note = " The file has uncommitted edits, so the lines may differ from the remote's.";
271
} else if (mode !== "permalink" && bookmark && bookmark.ahead !== null && bookmark.ahead > 0) {
272
note = ` ${bookmark.name} has unpushed changes; the remote may not show them yet.`;
273
}
f6bda77e Andy McCall 1 month ago ↶
274
}
275
}
276
const url = pick.provider.urlFor(pick.remote.url, t);
1f7fc1e3 Andy McCall 1 month ago ↶
277
if (!url) {
278
return;
279
}
280
if (mode === "open") {
f6bda77e Andy McCall 1 month ago ↶
281
await vscode.env.openExternal(vscode.Uri.parse(url));
1f7fc1e3 Andy McCall 1 month ago ↶
282
if (note) {
283
void vscode.window.showInformationMessage(`Oxygen:${note}`);
284
}
285
return;
f6bda77e Andy McCall 1 month ago ↶
286
}
1f7fc1e3 Andy McCall 1 month ago ↶
287
await vscode.env.clipboard.writeText(url);
288
const what = mode === "permalink" ? "Permalink" : "Link";
289
if (note) {
290
void vscode.window.showWarningMessage(`Oxygen: ${what} copied.${note}`);
291
} else {
292
vscode.window.setStatusBarMessage(`$(link) ${what} copied`, 4000);
293
}
294
}
295
296
function isSourceControl(arg: unknown): arg is vscode.SourceControl {
297
return typeof arg === "object" && arg !== null && "inputBox" in arg && "rootUri" in arg;
f6bda77e Andy McCall 1 month ago ↶
298
}
299
300
function repositoryFor2(uri: vscode.Uri): Repository | undefined {
301
const root = rootFor([...repositories.keys()], uri.fsPath);
302
return root ? repositories.get(root) : undefined;
bed495a9 Andy McCall 1 month ago ↶
303
}
304
305
export function deactivate(): void {
306
for (const r of repositories.values()) {
307
r.dispose();
308
}
309
repositories.clear();
310
}
311
312
/** One Repository per distinct oxy root among the workspace folders. */
313
async function discover(): Promise<void> {
314
const wanted = new Map<string, vscode.WorkspaceFolder>();
315
for (const folder of vscode.workspace.workspaceFolders ?? []) {
316
const info = await oxy.root(folder.uri.fsPath);
317
if (info) {
318
wanted.set(info.root, folder);
319
}
320
}
321
for (const [root, repo] of repositories) {
322
if (!wanted.has(root)) {
323
repo.dispose();
324
repositories.delete(root);
f6bda77e Andy McCall 1 month ago ↶
325
onDidChangeRepositoriesEmitter.fire();
bed495a9 Andy McCall 1 month ago ↶
326
}
327
}
810a1c53 Andy McCall 1 month ago ↶
328
if (wanted.size === 0 && (vscode.workspace.workspaceFolders ?? []).length > 0) {
329
output.appendLine("no Oxygen repository in the open folders (oxy root found none)");
330
}
bed495a9 Andy McCall 1 month ago ↶
331
for (const [root, folder] of wanted) {
332
if (!repositories.has(root)) {
333
const repo = new Repository(oxy, root, folder);
25103ddd Andy McCall 1 month ago ↶
334
repo.onDidChangeStatus((status) => {
bed495a9 Andy McCall 1 month ago ↶
335
decorations.refresh();
336
contentProvider.invalidate(root);
35d56b58 Andy McCall 1 month ago ↶
337
operations.refresh();
ab81294a Andy McCall 1 month ago ↶
338
blame.invalidate(root);
339
HistoryPanel.refresh(root);
25103ddd Andy McCall 1 month ago ↶
340
conflictLenses.refresh();
d2c9a584 Andy McCall 1 month ago ↶
341
bisectView.refresh();
342
trustView.refresh();
25103ddd Andy McCall 1 month ago ↶
343
// The commit action reads as a resolution while conflicts are resolved but uncommitted.
44bfd5ad Andy McCall 1 month ago ↶
344
box(repo).resolution = status.conflicted && status.conflicts.length > 0 && status.conflicts.every((c) => c.resolved === true);
345
void refreshSigning(repo);
346
updateBox(repo);
bed495a9 Andy McCall 1 month ago ↶
347
});
348
repositories.set(root, repo);
f6bda77e Andy McCall 1 month ago ↶
349
repo.onDidChangeStatus(() => onDidChangeStatusEmitter.fire(handleOf(repo)));
350
onDidChangeRepositoriesEmitter.fire();
bed495a9 Andy McCall 1 month ago ↶
351
output.appendLine(`repository ${root}`);
352
await repo.refresh();
353
}
354
}
355
void vscode.commands.executeCommand("setContext", "oxygen.hasRepository", repositories.size > 0);
356
}
357
358
function repositoryFor(arg: Repository | vscode.SourceControl | undefined): Repository | undefined {
359
if (arg instanceof Repository) {
360
return arg;
361
}
362
if (arg && "rootUri" in arg && arg.rootUri) {
363
return repositories.get(arg.rootUri.fsPath);
364
}
365
const active = vscode.window.activeTextEditor?.document.uri;
366
for (const repo of repositories.values()) {
367
if (active && repo.relativePath(active) !== undefined) {
368
return repo;
369
}
370
}
371
return repositories.size === 1 ? [...repositories.values()][0] : undefined;
372
}
373
374
async function openChanges(state: OxyResourceState): Promise<void> {
375
const repo = state.repository;
376
const title = `${basename(state.path)} (base ↔ working copy)`;
377
if (state.status === "Added") {
378
await vscode.commands.executeCommand("vscode.open", state.resourceUri);
379
return;
380
}
381
if (state.status === "Removed") {
382
await vscode.commands.executeCommand("vscode.open", baseUri(repo.root, state.path));
383
return;
384
}
385
await vscode.commands.executeCommand("vscode.diff", baseUri(repo.root, state.path), state.resourceUri, title);
386
}
387
388
async function commit(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
389
const repo = repositoryFor(arg);
390
if (!repo) {
391
void vscode.window.showErrorMessage("Oxygen: no repository for the active file.");
44bfd5ad Andy McCall 1 month ago ↶
392
return;
393
}
394
if (box(repo).amend) {
395
await amend(repo);
bed495a9 Andy McCall 1 month ago ↶
396
return;
397
}
398
let message = repo.sourceControl.inputBox.value.trim();
399
if (!message) {
400
message = (await vscode.window.showInputBox({ prompt: "Commit message", placeHolder: "What did you change?" }))?.trim() ?? "";
401
}
402
if (!message) {
403
return;
404
}
44bfd5ad Andy McCall 1 month ago ↶
405
if (!(await markersOk(repo))) {
406
return;
407
}
bed495a9 Andy McCall 1 month ago ↶
408
try {
409
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message));
44bfd5ad Andy McCall 1 month ago ↶
410
remember(repo, message);
bed495a9 Andy McCall 1 month ago ↶
411
repo.sourceControl.inputBox.value = "";
412
await repo.refresh();
413
} catch (e) {
414
showError(e);
415
}
416
}
417
810a1c53 Andy McCall 1 month ago ↶
418
async function initRepository(): Promise<void> {
419
const folders = vscode.workspace.workspaceFolders ?? [];
420
if (folders.length === 0) {
421
void vscode.window.showErrorMessage("Oxygen: open a folder first.");
422
return;
423
}
424
const folder = folders.length === 1 ? folders[0] : await vscode.window.showWorkspaceFolderPick({ placeHolder: "Folder to initialize as an Oxygen repository" });
425
if (!folder) {
426
return;
427
}
428
try {
429
await oxy.check(folder.uri.fsPath, ["init"]);
430
await discover();
431
void vscode.window.showInformationMessage(`Oxygen: initialized ${folder.name}.`);
432
} catch (e) {
433
showError(e);
434
}
435
}
436
35d56b58 Andy McCall 1 month ago ↶
437
async function discard(states: OxyResourceState[]): Promise<void> {
438
if (states.length === 0) {
439
return;
440
}
441
const repo = states[0].repository;
442
const names = states.length === 1 ? basename(states[0].path) : `${states.length} files`;
443
const pick = await vscode.window.showWarningMessage(`Discard changes in ${names}? This cannot be undone with oxy undo.`, { modal: true }, "Discard");
444
if (pick !== "Discard") {
445
return;
446
}
447
try {
448
await repo.oxy.discard(repo.root, states.map((s) => s.path));
449
await repo.refresh();
450
} catch (e) {
451
showError(e);
452
}
453
}
454
455
async function commitSelected(states: OxyResourceState[]): Promise<void> {
456
if (states.length === 0) {
457
return;
458
}
459
const repo = states[0].repository;
460
const message = await askMessage(repo, `Commit ${states.length === 1 ? basename(states[0].path) : `${states.length} files`}`);
461
if (!message) {
44bfd5ad Andy McCall 1 month ago ↶
462
return;
463
}
464
if (!(await markersOk(repo, states.map((s) => s.path)))) {
35d56b58 Andy McCall 1 month ago ↶
465
return;
466
}
467
try {
468
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Committing" }, () => repo.oxy.commit(repo.root, message, states.map((s) => s.path)));
44bfd5ad Andy McCall 1 month ago ↶
469
remember(repo, message);
35d56b58 Andy McCall 1 month ago ↶
470
repo.sourceControl.inputBox.value = "";
471
await repo.refresh();
472
} catch (e) {
473
showError(e);
474
}
475
}
476
477
async function commitHunks(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
478
const repo = repositoryFor(arg);
479
if (!repo) {
480
return;
481
}
482
let hunks;
483
try {
484
hunks = await repo.oxy.hunks(repo.root);
485
} catch (e) {
486
showError(e);
487
return;
488
}
489
if (hunks.length === 0) {
490
void vscode.window.showInformationMessage("Oxygen: nothing to commit.");
491
return;
492
}
493
const picks = await vscode.window.showQuickPick(
494
hunks.map((h) => ({
495
label: `$(diff) ${h.path}`,
496
description: `#${h.index}`,
497
detail: h.text.split("\n").filter((l) => l.startsWith("+") || l.startsWith("-") || l.startsWith("A ") || l.startsWith("D ") || l.startsWith("R ")).slice(0, 3).join(" ").slice(0, 160),
498
hunk: h,
499
picked: false,
500
})),
501
{ canPickMany: true, placeHolder: "Choose the hunks to commit; anything a chosen hunk needs comes along" },
502
);
503
if (!picks || picks.length === 0) {
504
return;
505
}
506
const message = await askMessage(repo, `Commit ${picks.length} hunk${picks.length === 1 ? "" : "s"}`);
507
if (!message) {
508
return;
509
}
510
try {
511
await repo.oxy.commitHunks(repo.root, message, picks.map((p) => p.hunk.index));
44bfd5ad Andy McCall 1 month ago ↶
512
remember(repo, message);
35d56b58 Andy McCall 1 month ago ↶
513
repo.sourceControl.inputBox.value = "";
514
await repo.refresh();
515
} catch (e) {
516
showError(e);
517
}
518
}
519
520
async function amend(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
521
const repo = repositoryFor(arg);
522
if (!repo) {
523
return;
524
}
525
const message = repo.sourceControl.inputBox.value.trim();
44bfd5ad Andy McCall 1 month ago ↶
526
if (!(await markersOk(repo))) {
527
return;
528
}
35d56b58 Andy McCall 1 month ago ↶
529
try {
530
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Amending" }, () => repo.oxy.amend(repo.root, message || undefined));
44bfd5ad Andy McCall 1 month ago ↶
531
remember(repo, message);
35d56b58 Andy McCall 1 month ago ↶
532
repo.sourceControl.inputBox.value = "";
44bfd5ad Andy McCall 1 month ago ↶
533
setAmend(repo, false);
35d56b58 Andy McCall 1 month ago ↶
534
await repo.refresh();
535
} catch (e) {
536
showError(e);
537
}
538
}
539
540
async function undo(arg: Repository | vscode.SourceControl | undefined, steps: number): Promise<void> {
541
const repo = repositoryFor(arg);
542
if (!repo || steps <= 0) {
543
return;
544
}
545
if (steps > 1) {
546
const pick = await vscode.window.showWarningMessage(`Undo the last ${steps} operations?`, { modal: true }, "Undo");
547
if (pick !== "Undo") {
548
return;
549
}
550
}
551
try {
552
let last = "";
553
for (let i = 0; i < steps; i++) {
554
last = await repo.oxy.undo(repo.root);
555
}
556
await repo.refresh();
557
vscode.window.setStatusBarMessage(`$(discard) ${last || "Undone"}`, 4000);
558
} catch (e) {
559
showError(e);
560
}
561
}
562
d2c9a584 Andy McCall 1 month ago ↶
563
// ---------------------------------------------------------------- subrepos, bisect, trust, tags, workspaces, clone
564
565
async function openSubrepo(state: SubrepoState): Promise<void> {
566
if (!state.info.actual) {
567
const pick = await vscode.window.showInformationMessage(`${state.info.path} is not checked out yet.`, "Update --init");
568
if (pick) {
569
await updateSubrepos(state.repository, true, [state.info.path]);
570
}
571
return;
572
}
573
const pick = await vscode.window.showQuickPick(
574
[
575
{ label: "$(folder-opened) Open in new window", action: "window" },
576
{ label: "$(cloud-download) Update to the pin", action: "update", description: state.info.at_pin ? "already at pin" : "moves the nested repository" },
577
],
578
{ placeHolder: `${state.info.path} · ${state.decorations.tooltip}` },
579
);
580
if (pick?.action === "window") {
581
await vscode.commands.executeCommand("vscode.openFolder", state.resourceUri, { forceNewWindow: true });
582
} else if (pick?.action === "update") {
583
await updateSubrepos(state.repository, true, [state.info.path]);
584
}
585
}
586
587
async function updateSubrepos(repo: Repository, init: boolean, paths: string[]): Promise<void> {
588
try {
589
const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: "Updating subrepos" }, () => repo.oxy.submoduleUpdate(repo.root, init, paths));
590
await repo.refresh();
591
const lines = out.trim().split("\n").filter(Boolean);
592
if (lines.some((l) => /missing|uncommitted|✖|x /.test(l))) {
593
void vscode.window.showWarningMessage(`Oxygen: ${lines.join("; ")}`);
594
} else {
595
vscode.window.setStatusBarMessage(`$(check) ${lines.join("; ") || "Subrepos up to date"}`, 5000);
596
}
597
} catch (e) {
598
showError(e);
599
}
600
}
601
602
async function bisect(arg: Repository | undefined, run: (repo: Repository) => Promise<string>): Promise<void> {
603
const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
604
if (!repo) {
605
return;
606
}
607
try {
608
const out = await run(repo);
609
await repo.refresh();
610
vscode.window.setStatusBarMessage(`$(search) ${out.split("\n")[0]}`, 5000);
611
} catch (e) {
612
showError(e);
613
}
614
}
615
616
async function bisectMark(arg: Repository | undefined, verdict: "good" | "bad", changes?: string[]): Promise<void> {
617
const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
618
if (!repo) {
619
return;
620
}
621
let picked = changes;
622
if (picked === undefined) {
623
const status = await repo.oxy.bisectStatus(repo.root).catch(() => null);
624
if (!status || status.state === "need-marks") {
625
const value = await vscode.window.showInputBox({ prompt: `A change, bookmark or tag known to be ${verdict}`, placeHolder: verdict === "good" ? "v1.2 or a ChangeId prefix" : "main" });
626
if (!value?.trim()) {
627
return;
628
}
629
picked = value.trim().split(/\s+/);
630
} else {
631
picked = [];
632
}
633
}
634
await bisect(repo, (r) => r.oxy.bisectMark(r.root, verdict, picked));
635
}
636
637
async function bisectRun(arg: Repository | undefined): Promise<void> {
638
const repo = arg instanceof Repository ? arg : repositoryFor(undefined);
639
if (!repo) {
640
return;
641
}
642
const value = await vscode.window.showInputBox({ prompt: "Command to run for each step (exit 0 good, 125 skip, anything else bad)", placeHolder: "make test" });
643
if (!value?.trim()) {
644
return;
645
}
646
await bisect(repo, (r) => Promise.resolve(vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Oxygen: bisecting", cancellable: false }, () => r.oxy.bisectRun(r.root, ["sh", "-c", value.trim()]))));
647
}
648
649
async function verify(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
650
const repo = repositoryFor(arg);
651
if (!repo) {
652
return;
653
}
654
try {
655
const results = await repo.oxy.verify(repo.root);
656
const icon = (s: string, revoked: boolean) => (s === "invalid" ? "$(error)" : s === "unsigned" ? "$(circle-outline)" : revoked ? "$(warning)" : "$(verified)");
657
const pick = await vscode.window.showQuickPick(
658
results.map((r) => ({
659
label: `${icon(r.status, r.revoked)} ${r.change_id.slice(0, 8)} ${r.status}${r.revoked ? " (key revoked)" : ""}`,
660
description: r.name ?? "",
661
detail: r.reason ?? undefined,
662
hash: r.hash,
663
})),
664
{ placeHolder: `${results.length} changes; ${results.filter((r) => r.status === "invalid").length} invalid`, matchOnDescription: true },
665
);
666
if (pick) {
667
await showChange(repo.root, pick.hash);
668
}
669
} catch (e) {
670
showError(e);
671
}
672
}
673
674
async function tags(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
675
const repo = repositoryFor(arg);
676
if (!repo) {
677
return;
678
}
679
try {
680
const list = await repo.oxy.tags(repo.root);
681
if (list.length === 0) {
682
const pick = await vscode.window.showInformationMessage("Oxygen: no tags yet.", "Create tag");
683
if (pick) {
684
await createTag(repo);
685
}
686
return;
687
}
688
const pick = await vscode.window.showQuickPick(
689
list.map((t) => ({ label: `$(tag) ${t.name}`, description: `${t.heads.length} head${t.heads.length === 1 ? "" : "s"}${t.signer ? ` · signed by ${t.signer}` : ""}`, detail: t.message || undefined, tag: t })),
690
{ placeHolder: "Check out a tag's state" },
691
);
692
if (!pick) {
693
return;
694
}
695
await repo.oxy.checkout(repo.root, [pick.tag.name]);
696
await repo.refresh();
697
} catch (e) {
698
if (e instanceof OxyError && e.message.includes("has changes")) {
699
void vscode.window.showWarningMessage("Oxygen: commit or discard your changes before checking out a tag.");
700
return;
701
}
702
showError(e);
703
}
704
}
705
706
async function createTag(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
707
const repo = repositoryFor(arg);
708
if (!repo) {
709
return;
710
}
711
const name = await vscode.window.showInputBox({ prompt: "Tag name (tags the current bookmark's heads)" });
712
if (!name?.trim()) {
713
return;
714
}
715
const message = (await vscode.window.showInputBox({ prompt: "Tag message (optional)" })) ?? "";
716
try {
717
await repo.oxy.tagCreate(repo.root, name.trim(), [], message);
718
await repo.refresh();
719
vscode.window.setStatusBarMessage(`$(tag) Tagged ${name.trim()}`, 4000);
720
} catch (e) {
721
showError(e);
722
}
723
}
724
725
async function addWorkspace(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
726
const repo = repositoryFor(arg);
727
if (!repo) {
728
return;
729
}
730
const folders = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Create workspace here", title: "Folder for the new workspace (must be empty or absent)" });
731
if (!folders?.[0]) {
732
return;
733
}
734
try {
735
await repo.oxy.workspaceAdd(repo.root, folders[0].fsPath);
736
const pick = await vscode.window.showInformationMessage(`Oxygen: workspace created at ${folders[0].fsPath}.`, "Open in new window");
737
if (pick) {
738
await vscode.commands.executeCommand("vscode.openFolder", folders[0], { forceNewWindow: true });
739
}
740
} catch (e) {
741
showError(e);
742
}
743
}
744
f532c426 Andy McCall 1 month ago ↶
745
/**
26410312 Andy McCall 1 month ago ↶
746
* vscode://oxygenhub.oxygen/resolve?url=…&bookmark=…&path=… from
f532c426 Andy McCall 1 month ago ↶
747
* OxygenHub's conflict resolver: find the open repository with that remote
748
* (or offer to clone it), switch to the bookmark if asked to, pull it, and
749
* open the file, where the conflict lenses take over.
750
*/
751
async function handleUri(uri: vscode.Uri): Promise<void> {
19639205 Andy McCall 1 month ago ↶
752
if (uri.path === "/plan") {
753
return applyPlan(uri);
754
}
f532c426 Andy McCall 1 month ago ↶
755
if (uri.path !== "/resolve") {
756
return;
757
}
758
const req = parseResolveQuery(uri.query);
759
if (typeof req === "string") {
760
vscode.window.showErrorMessage(`Oxygen: cannot open this conflict: ${req}.`);
761
return;
762
}
763
let found: { repo: Repository; remote: string } | undefined;
764
for (const repo of repositories.values()) {
765
const remotes = await repo.oxy.remotes(repo.root).catch(() => []);
766
const r = remotes.find((x) => sameRemote(x.url, req.url));
767
if (r) {
768
found = { repo, remote: r.name };
769
break;
770
}
771
}
772
if (!found) {
773
const pick = await vscode.window.showInformationMessage(
774
`Oxygen: no open folder is a checkout of ${req.url}. Clone it, open it, then follow the link again.`,
775
"Clone…",
776
);
777
if (pick) {
778
await cloneRepository(req.url);
779
}
780
return;
781
}
782
const { repo, remote } = found;
783
try {
784
if (repo.currentBookmark?.name !== req.bookmark) {
785
const ok = await vscode.window.showInformationMessage(
786
`Oxygen: the conflict is on ${req.bookmark}; this checkout is on ${repo.currentBookmark?.name ?? "another bookmark"}. Switch?`,
787
{ modal: true },
788
"Switch",
789
);
790
if (!ok) {
791
return;
792
}
793
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${req.bookmark}` }, () =>
794
repo.oxy.bookmarkSwitch(repo.root, req.bookmark),
795
);
796
}
797
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: pulling ${req.bookmark}` }, () =>
798
repo.oxy.pull(repo.root, remote),
799
);
800
await repo.refresh();
801
await vscode.commands.executeCommand("vscode.open", vscode.Uri.joinPath(vscode.Uri.file(repo.root), req.path));
19639205 Andy McCall 1 month ago ↶
802
} catch (e) {
803
showError(e);
804
}
805
}
806
807
/** The open checkout whose remote is `url`, with that remote's name. */
808
async function checkoutOf(url: string): Promise<{ repo: Repository; remote: string } | undefined> {
809
for (const repo of repositories.values()) {
810
const remotes = await repo.oxy.remotes(repo.root).catch(() => []);
811
const r = remotes.find((x) => sameRemote(x.url, url));
812
if (r) {
813
return { repo, remote: r.name };
814
}
815
}
816
return undefined;
817
}
818
819
/**
26410312 Andy McCall 1 month ago ↶
820
* vscode://oxygenhub.oxygen/plan?… from OxygenHub's swimlanes: squash
19639205 Andy McCall 1 month ago ↶
821
* or rebase a change of a proposal's bookmark here, where it is signed as
822
* you, then push the rewritten bookmark.
823
*/
824
async function applyPlan(uri: vscode.Uri): Promise<void> {
825
const plan = parsePlanQuery(uri.query);
826
if (typeof plan === "string") {
827
vscode.window.showErrorMessage(`Oxygen: cannot apply this plan: ${plan}.`);
828
return;
829
}
830
const found = await checkoutOf(plan.url);
831
if (!found) {
832
const pick = await vscode.window.showInformationMessage(`Oxygen: no open folder is a checkout of ${plan.url}. Clone it, open it, then follow the link again.`, "Clone…");
833
if (pick) {
834
await cloneRepository(plan.url);
835
}
836
return;
837
}
838
const { repo, remote } = found;
839
const [c, t] = [plan.change.slice(0, 12), plan.target.slice(0, 12)];
840
const what = plan.kind === "squash" ? `Squash #${c} into #${t}` : `Rebase #${c} onto #${t}`;
841
const ok = await vscode.window.showInformationMessage(`Oxygen: ${what} on ${plan.bookmark}? The rewritten changes are signed as you.`, { modal: true }, "Apply");
842
if (!ok) {
843
return;
844
}
845
try {
846
if (repo.currentBookmark?.name !== plan.bookmark) {
847
await repo.oxy.bookmarkSwitch(repo.root, plan.bookmark);
848
}
849
await repo.oxy.pull(repo.root, remote);
850
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: what }, () =>
851
plan.kind === "squash"
852
? repo.oxy.squash(repo.root, `#${plan.change}`, `#${plan.target}`)
853
: repo.oxy.rebase(repo.root, `#${plan.change}`, [`#${plan.target}`]),
854
);
855
await repo.refresh();
856
const push = await vscode.window.showInformationMessage(`Oxygen: ${what} done. Push ${plan.bookmark}? Its history changed, so this replaces it on ${remote}.`, "Push");
857
if (push) {
858
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: pushing ${plan.bookmark}` }, () =>
859
repo.oxy.push(repo.root, remote, plan.bookmark, true),
860
);
861
}
f532c426 Andy McCall 1 month ago ↶
862
} catch (e) {
863
showError(e);
013fefef Andy McCall 1 month ago ↶
864
}
865
}
866
867
/** Make a signing identity (oxy identity create): a composite key in your
868
* user key directory, and the author every repository then signs with. */
869
async function createIdentity(): Promise<void> {
870
const name = await vscode.window.showInputBox({ prompt: "Your name, as changes will show it", ignoreFocusOut: true, validateInput: (v) => (v.trim() ? undefined : "A name") });
871
if (!name) {
872
return;
873
}
874
const email = await vscode.window.showInputBox({ prompt: "Your email", ignoreFocusOut: true, validateInput: (v) => (/.+@.+/.test(v.trim()) ? undefined : "An email address") });
875
if (!email) {
876
return;
877
}
878
const home = process.env.HOME ?? process.cwd();
879
const r = await oxy.run(home, ["--plain", "identity", "create", "--name", name.trim(), "--email", email.trim()]);
880
if (r.code === 0) {
881
vscode.window.showInformationMessage(`Oxygen: your identity is ready: ${name.trim()} <${email.trim()}>. Changes you commit and push are signed with it.`);
882
for (const repo of repositories.values()) {
883
void repo.refresh();
884
}
885
} else {
886
vscode.window.showErrorMessage(`Oxygen: could not create an identity: ${r.stderr.trim() || r.stdout.toString("utf8").trim()}`);
f532c426 Andy McCall 1 month ago ↶
887
}
888
}
889
890
async function cloneRepository(prefill?: string): Promise<void> {
891
const url = await vscode.window.showInputBox({ prompt: "Repository to clone (path or https URL)", placeHolder: "https://host:8890/project", value: prefill });
d2c9a584 Andy McCall 1 month ago ↶
892
if (!url?.trim()) {
893
return;
894
}
895
const parent = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, canSelectMany: false, openLabel: "Clone into this folder" });
896
if (!parent?.[0]) {
897
return;
898
}
899
const name = url.trim().replace(/\/+$/, "").split("/").pop()?.replace(/\.oxy$/, "") || "repository";
900
const dest = vscode.Uri.joinPath(parent[0], name);
901
try {
902
await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Oxygen: cloning ${name}` }, () => oxy.clone(parent[0].fsPath, url.trim(), dest.fsPath));
903
const pick = await vscode.window.showInformationMessage(`Oxygen: cloned into ${dest.fsPath}.`, "Open", "Open in new window");
904
if (pick) {
905
await vscode.commands.executeCommand("vscode.openFolder", dest, { forceNewWindow: pick === "Open in new window" });
906
}
907
} catch (e) {
908
showError(e);
909
}
910
}
911
ab81294a Andy McCall 1 month ago ↶
912
// ---------------------------------------------------------------- history
913
914
async function showChange(root: string, hash: string): Promise<void> {
915
const repo = repositories.get(root);
916
if (!repo) {
917
return;
918
}
919
let info;
920
try {
921
info = await repo.oxy.show(repo.root, `#${hash.slice(0, 12)}`);
922
} catch (e) {
923
showError(e);
924
return;
925
}
926
const subject = info.message.split("\n")[0];
927
type Item = vscode.QuickPickItem & { action: string; path?: string };
928
const items: Item[] = [
929
{ label: "$(diff) Show diff", description: `${info.files.length} file${info.files.length === 1 ? "" : "s"}`, action: "diff" },
930
{ label: "$(arrow-right) Check out this state", description: "the change and its dependencies", action: "checkout" },
931
{ label: "$(edit) Describe…", description: "change the message", action: "describe" },
932
{ label: "$(tag) Tag…", action: "tag" },
933
{ label: "$(trash) Abandon", description: "drop the change; its content stays on disk if the working copy sat on it", action: "abandon" },
b4626290 Andy McCall 1 month ago ↶
934
...historyItems(info),
ab81294a Andy McCall 1 month ago ↶
935
{ label: "$(copy) Copy ChangeId", action: "copy" },
936
...info.files.map((f) => ({ label: `$(file) ${f.path}`, description: `${f.kind}${f.from ? ` from ${f.from}` : ""}`, action: "file", path: f.path })),
937
];
938
const pick = await vscode.window.showQuickPick(items, {
939
title: `${info.change_id.slice(0, 8)} ${subject}`,
940
placeHolder: `${info.author} · ${timeAgo(info.when_ms)} · ${info.signature?.status ?? "unsigned"}${info.signature?.revoked ? " (key revoked)" : ""} · ${info.deps.length} dependenc${info.deps.length === 1 ? "y" : "ies"}`,
941
matchOnDescription: true,
942
});
943
if (!pick) {
944
return;
945
}
946
try {
947
switch (pick.action) {
948
case "diff":
949
case "file": {
950
const text = await repo.oxy.diffText(repo.root, `#${hash.slice(0, 12)}`);
951
const body = `# ${info.change_id.slice(0, 8)} ${subject}\n# ${info.author} · ${new Date(info.when_ms).toLocaleString()}\n\n${text}`;
952
const doc = await vscode.workspace.openTextDocument({ content: body, language: "diff" });
953
await vscode.window.showTextDocument(doc, { preview: true });
954
return;
955
}
956
case "checkout":
957
await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`]);
958
break;
959
case "describe": {
960
const message = await vscode.window.showInputBox({ prompt: "New message", value: info.message });
961
if (!message?.trim()) {
962
return;
963
}
964
await repo.oxy.describe(repo.root, `#${hash.slice(0, 12)}`, message.trim());
965
break;
966
}
967
case "tag": {
968
const name = await vscode.window.showInputBox({ prompt: "Tag name" });
969
if (!name?.trim()) {
970
return;
971
}
972
await repo.oxy.tagCreate(repo.root, name.trim(), [`#${hash.slice(0, 12)}`]);
973
break;
974
}
975
case "abandon": {
976
const confirm = await vscode.window.showWarningMessage(`Abandon "${subject}"?`, { modal: true }, "Abandon");
977
if (confirm !== "Abandon") {
978
return;
979
}
980
const out = await repo.oxy.abandon(repo.root, `#${hash.slice(0, 12)}`);
981
vscode.window.setStatusBarMessage(`$(trash) ${out.split("\n")[0]}`, 5000);
982
break;
983
}
984
case "copy":
985
await vscode.env.clipboard.writeText(info.change_id);
b4626290 Andy McCall 1 month ago ↶
986
return;
987
case "split":
988
case "squash":
989
case "rebase":
990
await historyAction(repo, info, pick.action);
ab81294a Andy McCall 1 month ago ↶
991
return;
992
}
993
await repo.refresh();
994
} catch (e) {
995
if (e instanceof OxyError && e.message.includes("has changes")) {
996
const confirm = await vscode.window.showWarningMessage("The working copy has uncommitted changes. Discard them and check out?", { modal: true }, "Discard and check out");
997
if (confirm) {
998
try {
999
await repo.oxy.checkout(repo.root, [`#${hash.slice(0, 12)}`], true);
1000
await repo.refresh();
1001
} catch (e2) {
1002
showError(e2);
1003
}
1004
}
1005
return;
1006
}
b4626290 Andy McCall 1 month ago ↶
1007
showError(e);
1008
}
1009
}
1010
1011
// ------------------------------------------------ history rewriting (V6)
1012
1013
type HistoryAction = "split" | "squash" | "rebase";
1014
1015
/** The rewriting actions a change offers: split for a head with several files, squash when it has dependencies, rebase always. */
1016
function historyItems(info: ShowInfo): (vscode.QuickPickItem & { action: string })[] {
1017
const items: (vscode.QuickPickItem & { action: string })[] = [];
1018
if (info.head && info.files.length > 1) {
1019
items.push({ label: "$(split-horizontal) Split…", description: "chosen files become a separate first change", action: "split" });
1020
}
1021
if (info.deps.length > 0) {
1022
items.push({ label: "$(fold) Squash into a dependency…", description: "fold this change into one it depends on", action: "squash" });
1023
}
1024
items.push({ label: "$(git-pull-request-go-to-changes) Rebase onto…", description: "re-express against another state", action: "rebase" });
1025
return items;
1026
}
1027
1028
/** Run `action` on a change: choose, confirm, run, then offer Undo (oxy undo reverses every rewrite). */
1029
async function historyAction(repo: Repository, info: ShowInfo, action: HistoryAction): Promise<void> {
1030
const change = `#${info.hash.slice(0, 12)}`;
1031
const subject = info.message.split("\n")[0] || "(no description)";
1032
let run: () => Promise<string>;
1033
let question: string;
1034
if (action === "split") {
1035
if (!info.head) {
1036
void vscode.window.showInformationMessage("Oxygen: only a head can be split; nothing may depend on it yet.");
1037
return;
1038
}
1039
const picked = await vscode.window.showQuickPick(
1040
info.files.map((f) => ({ label: f.path, description: f.kind, picked: false })),
1041
{ title: `Split ${info.change_id.slice(0, 8)}: the files you choose become the first change`, canPickMany: true },
1042
);
1043
if (!picked || picked.length === 0) {
1044
return;
1045
}
1046
if (picked.length === info.files.length) {
1047
void vscode.window.showInformationMessage("Oxygen: leave at least one file for the second change.");
1048
return;
1049
}
1050
const paths = picked.map((p) => p.label);
1051
question = `Split "${subject}"? ${paths.length} file${paths.length === 1 ? "" : "s"} (${paths.join(", ")}) become a separate change; the rest stay.`;
1052
run = () => repo.oxy.split(repo.root, change, paths);
1053
} else if (action === "squash") {
1054
if (info.deps.length === 0) {
1055
void vscode.window.showInformationMessage("Oxygen: this change has no dependency to fold into.");
1056
return;
1057
}
1058
const log = await repo.oxy.log(repo.root, 500);
1059
const byHash = new Map(log.changes.map((c) => [c.hash, c]));
1060
const choices = info.deps.map((d) => {
1061
const c = byHash.get(d);
1062
return { label: c ? c.message.split("\n")[0] || "(no description)" : d.slice(0, 12), description: c ? c.change_id.slice(0, 8) : "", hash: d };
1063
});
1064
const into = choices.length === 1 ? choices[0] : await vscode.window.showQuickPick(choices, { title: `Squash ${info.change_id.slice(0, 8)} into…` });
1065
if (!into) {
1066
return;
1067
}
1068
question = `Squash "${subject}" into "${into.label}"? The dependency keeps its ChangeId and absorbs these edits.`;
1069
run = () => repo.oxy.squash(repo.root, change, `#${into.hash.slice(0, 12)}`);
1070
} else {
1071
const bookmarks = await repo.oxy.bookmarks(repo.root);
1072
type Onto = vscode.QuickPickItem & { onto?: string };
1073
const choices: Onto[] = [
1074
...bookmarks.map((b) => ({ label: `$(bookmark) ${b.name}`, description: b.current ? "current bookmark" : "", onto: b.name })),
1075
{ label: "$(edit) Another change, bookmark or tag…" },
1076
];
1077
const pick = await vscode.window.showQuickPick(choices, { title: `Rebase ${info.change_id.slice(0, 8)} onto…` });
1078
if (!pick) {
1079
return;
1080
}
1081
let onto = pick.onto;
1082
if (!onto) {
1083
onto = (await vscode.window.showInputBox({ prompt: "ChangeId prefix, #hash prefix, bookmark or tag" }))?.trim();
1084
if (!onto) {
1085
return;
1086
}
1087
}
1088
const target = onto;
1089
question = `Rebase "${subject}" onto ${target}? The change is re-expressed against that state; its ChangeId stays.`;
1090
run = () => repo.oxy.rebase(repo.root, change, [target]);
1091
}
1092
const verb = action[0].toUpperCase() + action.slice(1);
1093
if ((await vscode.window.showWarningMessage(question, { modal: true, detail: "Undo reverses it (Oxygen: Undo, or oxy undo)." }, verb)) !== verb) {
1094
return;
1095
}
1096
try {
1097
const out = await run();
1098
await repo.refresh();
1099
HistoryPanel.refresh(repo.root);
1100
const undo = await vscode.window.showInformationMessage(`Oxygen: ${out.split("\n")[0].replace(/^\* /, "")}`, "Undo");
1101
if (undo === "Undo") {
1102
await repo.oxy.undo(repo.root);
1103
await repo.refresh();
1104
HistoryPanel.refresh(repo.root);
1105
}
1106
} catch (e) {
1107
showError(e);
1108
}
1109
}
1110
1111
/** A history-graph context menu command: the webview passes `{ root, hash }`. */
1112
async function historyContext(ctx: { root?: string; hash?: string } | undefined, action: HistoryAction): Promise<void> {
1113
const repo = ctx?.root ? repositories.get(ctx.root) : undefined;
1114
if (!repo || !ctx?.hash) {
1115
return;
1116
}
1117
try {
1118
const info = await repo.oxy.show(repo.root, `#${ctx.hash.slice(0, 12)}`);
1119
await historyAction(repo, info, action);
1120
} catch (e) {
ab81294a Andy McCall 1 month ago ↶
1121
showError(e);
1122
}
44bfd5ad Andy McCall 1 month ago ↶
1123
}
1124
1125
// ------------------------------------------------------ commit box (V7)
1126
1127
interface BoxState {
1128
amend: boolean;
1129
resolution: boolean;
1130
signing: string;
1131
checkedMs: number;
1132
}
1133
1134
const boxes = new Map<string, BoxState>();
1135
let extensionContext: vscode.ExtensionContext | undefined;
1136
1137
function box(repo: Repository): BoxState {
1138
let b = boxes.get(repo.root);
1139
if (!b) {
1140
b = { amend: false, resolution: false, signing: "", checkedMs: 0 };
1141
boxes.set(repo.root, b);
1142
}
1143
return b;
1144
}
1145
1146
/** Placeholder and the Ctrl+Enter action follow amend mode, resolution and signing. */
1147
function updateBox(repo: Repository): void {
1148
const b = box(repo);
1149
repo.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: b.amend ? "Amend" : b.resolution ? "Commit resolution" : "Commit", arguments: [repo] };
1150
repo.sourceControl.inputBox.placeholder = placeholder(b);
1151
}
1152
1153
/** Re-read how commits will be signed, at most every 30 seconds. */
1154
async function refreshSigning(repo: Repository, force = false): Promise<void> {
1155
const b = box(repo);
1156
if (!force && Date.now() - b.checkedMs < 30_000) {
1157
return;
1158
}
1159
b.checkedMs = Date.now();
1160
try {
1161
b.signing = signingLabel(await repo.oxy.identity(repo.root));
1162
} catch {
1163
b.signing = "";
1164
}
1165
updateBox(repo);
1166
}
1167
1168
function setAmend(repo: Repository, on: boolean): void {
1169
box(repo).amend = on;
1170
void vscode.commands.executeCommand("setContext", "oxygen.amending", [...boxes.values()].some((b) => b.amend));
1171
updateBox(repo);
1172
}
1173
1174
async function toggleAmend(arg: Repository | vscode.SourceControl | undefined, on: boolean): Promise<void> {
1175
const repo = repositoryFor(arg);
1176
if (!repo) {
1177
return;
1178
}
1179
setAmend(repo, on);
1180
if (on && !repo.sourceControl.inputBox.value.trim()) {
1181
// Start from the message being amended.
1182
try {
1183
const last = (await repo.oxy.log(repo.root, 2)).changes.find((c) => !c.working_copy);
1184
if (last) {
1185
repo.sourceControl.inputBox.value = last.message;
1186
}
1187
} catch {
1188
// An empty box keeps the message anyway.
1189
}
1190
}
1191
}
1192
1193
function historyOf(repo: Repository): MessageHistory {
1194
return new MessageHistory(extensionContext?.workspaceState.get<string[]>(`oxygen.messages:${repo.root}`) ?? []);
1195
}
1196
1197
function remember(repo: Repository, message: string): void {
1198
const h = historyOf(repo);
1199
h.add(message);
1200
void extensionContext?.workspaceState.update(`oxygen.messages:${repo.root}`, h.entries);
1201
}
1202
1203
async function messageHistory(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1204
const repo = repositoryFor(arg);
1205
if (!repo) {
1206
return;
1207
}
1208
const entries = historyOf(repo).entries;
1209
if (entries.length === 0) {
1210
void vscode.window.showInformationMessage("Oxygen: no messages yet; they are remembered as you commit.");
1211
return;
1212
}
1213
const pick = await vscode.window.showQuickPick(
1214
entries.map((m) => ({ label: m.split("\n")[0], detail: m.includes("\n") ? m.split("\n").slice(1).join(" ").trim().slice(0, 160) : undefined, message: m })),
1215
{ title: "Previous commit messages", placeHolder: "Put one back in the commit box", matchOnDetail: true },
1216
);
1217
if (pick) {
1218
repo.sourceControl.inputBox.value = pick.message;
1219
}
1220
}
1221
1222
/**
1223
* Warn before committing files that still hold conflict markers: those oxy
1224
* reports (markers left in a conflicted file) and any text file whose lines
1225
* open or close a conflict. `paths` limits the check to what is committed.
1226
*/
1227
async function markersOk(repo: Repository, paths?: string[]): Promise<boolean> {
1228
const status = repo.status;
1229
if (!status) {
1230
return true;
1231
}
1232
const found: { path: string; line?: number }[] = [];
1233
for (const p of status.paths) {
1234
if (p.status === "Removed" || (paths && !paths.includes(p.path))) {
1235
continue;
1236
}
1237
if (p.status === "ConflictEdited") {
1238
found.push({ path: p.path });
1239
continue;
1240
}
1241
try {
1242
const file = join(repo.root, p.path);
1243
if ((await stat(file)).size > 4 * 1024 * 1024) {
1244
continue;
1245
}
1246
const text = (await readFile(file)).toString("utf8");
1247
const lines = conflictMarkerLines(text);
1248
if (lines.length > 0) {
1249
found.push({ path: p.path, line: lines[0] });
1250
}
1251
} catch {
1252
// Unreadable or gone: nothing to check.
1253
}
1254
}
1255
if (found.length === 0) {
1256
return true;
1257
}
1258
const list = found.slice(0, 5).map((f) => (f.line ? `${f.path} (line ${f.line})` : f.path)).join(", ") + (found.length > 5 ? ` and ${found.length - 5} more` : "");
1259
const pick = await vscode.window.showWarningMessage(
1260
`Conflict markers remain in ${list}. Commit anyway?`,
1261
{ modal: true, detail: "Committing records the marker lines as ordinary text." },
1262
"Commit anyway",
1263
`Open ${basename(found[0].path)}`,
1264
);
1265
if (pick?.startsWith("Open ")) {
1266
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(join(repo.root, found[0].path)));
1267
const line = Math.max(0, (found[0].line ?? 1) - 1);
1268
await vscode.window.showTextDocument(doc, { selection: new vscode.Range(line, 0, line, 0) });
1269
return false;
1270
}
1271
return pick === "Commit anyway";
ab81294a Andy McCall 1 month ago ↶
1272
}
1273
1274
async function fileHistory(uri: vscode.Uri | undefined): Promise<void> {
1275
if (!uri) {
1276
return;
1277
}
1278
const repo = [...repositories.values()].find((r) => r.relativePath(uri) !== undefined);
1279
const rel = repo?.relativePath(uri);
1280
if (!repo || !rel) {
1281
void vscode.window.showInformationMessage("Oxygen: this file is not in an Oxygen repository.");
1282
return;
1283
}
1284
try {
1285
const log = await repo.oxy.log(repo.root, 200, rel);
1286
const changes = log.changes.filter((c) => !c.working_copy);
1287
if (changes.length === 0) {
1288
void vscode.window.showInformationMessage(`Oxygen: no committed change touches ${rel}.`);
1289
return;
1290
}
1291
const pick = await vscode.window.showQuickPick(
1292
changes.map((c, i) => ({
1293
label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
1294
description: `${c.change_id.slice(0, 8)} · ${c.author} · ${timeAgo(c.when_ms)}`,
1295
change: c,
1296
previous: changes[i + 1],
1297
})),
1298
{ placeHolder: `${changes.length} change${changes.length === 1 ? "" : "s"} touched ${rel}; pick one to compare with the version before it`, matchOnDescription: true },
1299
);
1300
if (!pick) {
1301
return;
1302
}
1303
const after = oxyUriFor(repo.root, rel, pick.change.hash);
1304
if (pick.previous) {
1305
const before = oxyUriFor(repo.root, rel, pick.previous.hash);
1306
await vscode.commands.executeCommand("vscode.diff", before, after, `${basename(rel)} (${pick.previous.change_id.slice(0, 8)} ↔ ${pick.change.change_id.slice(0, 8)})`);
1307
} else {
1308
await vscode.commands.executeCommand("vscode.open", after);
1309
}
1310
} catch (e) {
1311
showError(e);
1312
}
1313
}
1314
1315
function oxyUriFor(root: string, rel: string, hash: string): vscode.Uri {
1316
return oxyUri(root, rel, `#${hash.slice(0, 12)}`);
1317
}
1318
c8f95589 Andy McCall 1 month ago ↶
1319
// ---------------------------------------------------------------- bookmarks
1320
1321
async function switchBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1322
const repo = repositoryFor(arg);
1323
if (!repo) {
1324
return;
1325
}
1326
let bookmarks;
1327
try {
1328
bookmarks = await repo.oxy.bookmarks(repo.root);
1329
} catch (e) {
1330
showError(e);
1331
return;
1332
}
1333
type Item = vscode.QuickPickItem & { action: "switch" | "create" | "merge" | "delete"; name?: string };
1334
const items: Item[] = bookmarks.map((b) => ({
1335
label: `${b.current ? "$(circle-filled)" : "$(bookmark)"} ${b.name}`,
1336
description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}${b.ahead ? ` $(arrow-up)${b.ahead}` : ""}${b.behind ? ` $(arrow-down)${b.behind}` : ""}`,
1337
detail: b.current ? "current bookmark" : undefined,
1338
action: "switch",
1339
name: b.name,
1340
}));
1341
items.push(
1342
{ label: "$(add) Create bookmark…", action: "create", alwaysShow: true },
1343
{ label: "$(git-merge) Merge a bookmark into the current one…", action: "merge", alwaysShow: true },
1344
{ label: "$(trash) Delete a bookmark…", action: "delete", alwaysShow: true },
1345
);
1346
const pick = await vscode.window.showQuickPick(items, { placeHolder: "Switch to a bookmark" });
1347
if (!pick) {
1348
return;
1349
}
1350
switch (pick.action) {
1351
case "create":
1352
return createBookmark(repo);
1353
case "merge":
1354
return mergeBookmark(repo);
1355
case "delete":
1356
return deleteBookmark(repo);
1357
case "switch":
1358
if (pick.name && !bookmarks.find((b) => b.name === pick.name)?.current) {
1359
await doSwitch(repo, pick.name);
1360
}
1361
}
1362
}
1363
1364
async function doSwitch(repo: Repository, name: string): Promise<void> {
1365
try {
1366
await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Switching to ${name}` }, () => repo.oxy.bookmarkSwitch(repo.root, name));
1367
} catch (e) {
1368
if (e instanceof OxyError && e.message.includes("has changes")) {
1369
const pick = await vscode.window.showWarningMessage(`The working copy has uncommitted changes. Switching to ${name} would discard them.`, { modal: true }, "Discard and switch");
1370
if (pick === "Discard and switch") {
1371
try {
1372
await repo.oxy.bookmarkSwitch(repo.root, name, true);
1373
} catch (e2) {
1374
showError(e2);
1375
}
1376
}
1377
} else {
1378
showError(e);
1379
}
1380
}
1381
await repo.refresh();
1382
}
1383
1384
async function createBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1385
const repo = repositoryFor(arg);
1386
if (!repo) {
1387
return;
1388
}
1389
const name = await vscode.window.showInputBox({
1390
prompt: "New bookmark name (created at the working copy's base)",
1391
validateInput: (v) => (!v.trim() || v.startsWith("-") || v.includes("..") || v.includes("//") || /[\s@{]/.test(v) ? "Not a valid bookmark name" : undefined),
1392
});
1393
if (!name) {
1394
return;
1395
}
1396
try {
1397
await repo.oxy.bookmarkCreate(repo.root, name.trim());
1398
const pick = await vscode.window.showInformationMessage(`Oxygen: created bookmark ${name}.`, "Switch to it");
1399
if (pick) {
1400
await doSwitch(repo, name.trim());
1401
}
1402
await repo.refresh();
1403
} catch (e) {
1404
showError(e);
1405
}
1406
}
1407
1408
async function pickBookmark(repo: Repository, placeHolder: string, excludeCurrent: boolean): Promise<string | undefined> {
1409
const bookmarks = await repo.oxy.bookmarks(repo.root);
1410
const items = bookmarks.filter((b) => !excludeCurrent || !b.current).map((b) => ({ label: b.name, description: `${b.heads.length} head${b.heads.length === 1 ? "" : "s"}` }));
1411
if (items.length === 0) {
1412
void vscode.window.showInformationMessage("Oxygen: no other bookmarks.");
1413
return undefined;
1414
}
1415
return (await vscode.window.showQuickPick(items, { placeHolder }))?.label;
1416
}
1417
1418
async function mergeBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1419
const repo = repositoryFor(arg);
1420
if (!repo) {
1421
return;
1422
}
1423
try {
1424
const name = await pickBookmark(repo, `Merge into ${repo.currentBookmark?.name ?? "the current bookmark"}`, true);
1425
if (!name) {
1426
return;
1427
}
1428
const out = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Merging ${name}` }, () => repo.oxy.merge(repo.root, name));
1429
await repo.refresh();
1430
if (repo.status?.conflicted) {
1431
void vscode.window.showWarningMessage(`Oxygen: merged ${name}; the result has conflicts to resolve.`);
1432
} else {
1433
vscode.window.setStatusBarMessage(`$(git-merge) ${out.split("\n")[0]}`, 5000);
1434
}
1435
} catch (e) {
1436
showError(e);
1437
}
1438
}
1439
1440
async function deleteBookmark(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1441
const repo = repositoryFor(arg);
1442
if (!repo) {
1443
return;
1444
}
1445
try {
1446
const name = await pickBookmark(repo, "Delete which bookmark? Its changes stay.", true);
1447
if (!name) {
1448
return;
1449
}
1450
const pick = await vscode.window.showWarningMessage(`Delete bookmark ${name}?`, { modal: true }, "Delete");
1451
if (pick !== "Delete") {
1452
return;
1453
}
1454
await repo.oxy.bookmarkDelete(repo.root, name);
1455
await repo.refresh();
1456
} catch (e) {
1457
showError(e);
1458
}
1459
}
1460
1461
// ---------------------------------------------------------------- sync
1462
1463
async function pickRemote(repo: Repository): Promise<string | undefined> {
1464
const remotes = await repo.oxy.remotes(repo.root);
1465
if (remotes.length === 0) {
1466
void vscode.window.showErrorMessage("Oxygen: this repository has no remotes. Add one with oxy remote add <name> <url>.");
1467
return undefined;
1468
}
1469
if (remotes.length === 1) {
1470
return remotes[0].name;
1471
}
1472
return (await vscode.window.showQuickPick(remotes.map((r) => ({ label: r.name, description: r.url })), { placeHolder: "Remote" }))?.label;
1473
}
1474
1475
async function fetch(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1476
const repo = repositoryFor(arg);
1477
if (!repo) {
1478
return;
1479
}
1480
try {
1481
const remote = await pickRemote(repo);
1482
if (!remote) {
1483
return;
1484
}
1485
const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Fetching from ${remote}` }, () => repo.oxy.fetch(repo.root, remote));
1486
await repo.refresh();
1487
const fresh = info.refs.filter((r) => r.new > 0);
1488
const summary = fresh.length ? fresh.map((r) => `${r.name}: ${r.new} new`).join(", ") : "nothing new";
1489
vscode.window.setStatusBarMessage(`$(cloud-download) ${remote}: ${summary}`, 5000);
1490
if (info.unverified) {
1491
void vscode.window.showWarningMessage(`Oxygen: ${remote} has no trust policy; nothing fetched from it was verified.`);
1492
}
1493
} catch (e) {
1494
showError(e);
1495
}
1496
}
1497
1498
async function pull(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1499
const repo = repositoryFor(arg);
1500
if (!repo) {
1501
return;
1502
}
1503
try {
1504
const remote = await pickRemote(repo);
1505
if (!remote) {
1506
return;
1507
}
1508
const info = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pulling from ${remote}` }, () => repo.oxy.pull(repo.root, remote));
1509
await repo.refresh();
1510
if (!info.changed) {
1511
vscode.window.setStatusBarMessage("$(check) Already up to date", 4000);
1512
} else if (info.conflicted) {
1513
void vscode.window.showWarningMessage("Oxygen: pulled; the result has conflicts to resolve.");
1514
} else if (!info.checked_out) {
1515
void vscode.window.showInformationMessage("Oxygen: pulled; the working copy was not updated because it has changes. Check out when ready.");
1516
} else {
1517
vscode.window.setStatusBarMessage(`$(cloud-download) Pulled from ${remote}`, 4000);
1518
}
1519
} catch (e) {
1520
showError(e);
1521
}
1522
}
1523
1524
async function push(arg: Repository | vscode.SourceControl | undefined, chooseBookmark: boolean): Promise<void> {
1525
const repo = repositoryFor(arg);
1526
if (!repo) {
1527
return;
1528
}
1529
try {
1530
const remote = await pickRemote(repo);
1531
if (!remote) {
1532
return;
1533
}
1534
let bookmark: string | undefined;
1535
if (chooseBookmark) {
1536
bookmark = await pickBookmark(repo, "Push which bookmark?", false);
1537
if (!bookmark) {
1538
return;
1539
}
1540
}
1541
if (vscode.workspace.getConfiguration("oxygen").get<boolean>("confirmSync", true)) {
1542
const pick = await vscode.window.showInformationMessage(`Push ${bookmark ?? repo.currentBookmark?.name ?? "the current bookmark"} to ${remote}?`, { modal: false }, "Push", "Always push without asking");
1543
if (!pick) {
1544
return;
1545
}
1546
if (pick === "Always push without asking") {
1547
await vscode.workspace.getConfiguration("oxygen").update("confirmSync", false, vscode.ConfigurationTarget.Global);
1548
}
1549
}
1550
const results = await vscode.window.withProgress({ location: vscode.ProgressLocation.SourceControl, title: `Pushing to ${remote}` }, () => repo.oxy.push(repo.root, remote, bookmark));
1551
await repo.refresh();
1552
const rejected = results.filter((r) => !r.ok);
1553
if (rejected.length === 0) {
1554
vscode.window.setStatusBarMessage(`$(cloud-upload) Pushed to ${remote}`, 4000);
1555
return;
1556
}
1557
const reason = rejected.map((r) => `${r.name}: ${r.reason}`).join("; ");
1558
const pick = await vscode.window.showErrorMessage(`Oxygen: push rejected. ${reason}`, "Pull", "Show Output");
1559
if (pick === "Pull") {
1560
await pull(repo);
1561
} else if (pick === "Show Output") {
1562
output.show();
1563
}
1564
} catch (e) {
1565
if (e instanceof OxyError && e.message.includes("pull first")) {
1566
const pick = await vscode.window.showWarningMessage(`Oxygen: ${e.message}`, "Pull");
1567
if (pick === "Pull") {
1568
await pull(repo);
1569
}
1570
return;
1571
}
1572
showError(e);
1573
}
1574
}
1575
1576
async function sync(arg: Repository | vscode.SourceControl | undefined): Promise<void> {
1577
const repo = repositoryFor(arg);
1578
if (!repo) {
1579
return;
1580
}
1581
await pull(repo);
1582
if (repo.status?.conflicted) {
1583
return;
1584
}
1585
if ((repo.currentBookmark?.ahead ?? 0) > 0) {
1586
await push(repo, false);
1587
}
1588
}
1589
35d56b58 Andy McCall 1 month ago ↶
1590
/** The commit message from the input box, else a prompt. */
1591
async function askMessage(repo: Repository, prompt: string): Promise<string | undefined> {
1592
const fromBox = repo.sourceControl.inputBox.value.trim();
1593
if (fromBox) {
1594
return fromBox;
1595
}
1596
return (await vscode.window.showInputBox({ prompt, placeHolder: "Commit message" }))?.trim() || undefined;
1597
}
1598
bed495a9 Andy McCall 1 month ago ↶
1599
async function showLog(): Promise<void> {
1600
const repo = repositoryFor(undefined);
1601
if (!repo) {
1602
return;
1603
}
1604
try {
1605
const log = await repo.oxy.log(repo.root, 50);
1606
const items = log.changes
1607
.filter((c) => !c.working_copy)
1608
.map((c) => ({
1609
label: `${c.head ? "$(circle-filled)" : "$(circle-outline)"} ${c.message.split("\n")[0]}`,
1610
description: `${c.change_id.slice(0, 8)} · ${c.author}`,
1611
detail: `${new Date(c.when_ms).toLocaleString()} · ${c.signature?.status ?? "unsigned"}${c.signature?.revoked ? " (key revoked)" : ""}`,
1612
change: c,
1613
}));
1614
const pick = await vscode.window.showQuickPick(items, { placeHolder: `${log.bookmark}: ${log.total - 1} changes`, matchOnDescription: true, matchOnDetail: true });
1615
if (pick) {
1616
const doc = await vscode.workspace.openTextDocument({ content: pick.change.message, language: "markdown" });
1617
await vscode.window.showTextDocument(doc, { preview: true });
1618
}
1619
} catch (e) {
1620
showError(e);
1621
}
1622
}
1623
1624
function showError(e: unknown): void {
1625
const message = e instanceof OxyError ? e.message : String(e);
1626
void vscode.window.showErrorMessage(`Oxygen: ${message}`, "Show Output").then((pick) => pick && output.show());
1627
}