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