OXYGENHUB
◆ v0.17.1 oxygen-vscode / src / repository.ts
11.9 KiB · last changed 0.15.0: a splash of colour 1 month ago source blame history raw
1
// One Oxygen repository as VS Code sees it: the source control provider,
2
// its resource groups, the status bar item, and the watchers that keep
3
// them fresh (spec §3, §5.1, §5.4).
4
5
import * as vscode from "vscode";
6
import { basename, join, relative } from "node:path";
7
import { isInStore } from "./paths";
8
import { BookmarkInfo, Oxy, PathStatus, StatusInfo, SubrepoInfo } from "./oxy";
9
import { baseUri } from "./contentProvider";
10
11
const STATUS_LETTER: Record<PathStatus, string> = {
12
  Added: "A",
13
  Modified: "M",
14
  Removed: "D",
15
  Renamed: "R",
16
  ModeChanged: "X",
17
  ConflictEdited: "‼",
18
  Resolved: "✔",
19
};
20
21
const STATUS_WORD: Record<PathStatus, string> = {
22
  Added: "added",
23
  Modified: "modified",
24
  Removed: "removed",
25
  Renamed: "renamed",
26
  ModeChanged: "mode changed",
27
  ConflictEdited: "conflict markers remain",
28
  Resolved: "conflict resolved",
29
};
30
31
const STATUS_COLOR: Record<PathStatus, string> = {
32
  Added: "oxygen.added",
33
  Modified: "oxygen.modified",
34
  Removed: "oxygen.removed",
35
  Renamed: "oxygen.renamed",
36
  ModeChanged: "oxygen.modified",
37
  ConflictEdited: "oxygen.conflict",
38
  Resolved: "oxygen.resolved",
39
};
40
41
export class OxyResourceState implements vscode.SourceControlResourceState {
42
  readonly resourceUri: vscode.Uri;
43
  readonly decorations: vscode.SourceControlResourceDecorations;
44
  readonly command: vscode.Command;
45
46
  constructor(
47
    readonly repository: Repository,
48
    readonly path: string,
49
    readonly status: PathStatus,
50
  ) {
51
    this.resourceUri = vscode.Uri.file(join(repository.root, path));
52
    this.decorations = {
53
      strikeThrough: status === "Removed",
54
      tooltip: STATUS_WORD[status],
55
      faded: false,
56
    };
57
    this.command = { command: "oxygen.openChanges", title: "Open Changes", arguments: [this] };
58
    this.contextValue = /\.oxy-conflict-\d+$/.test(path) ? "candidate" : status === "ConflictEdited" ? "markers" : status;
59
  }
60
61
  contextValue: string;
62
63
  get letter(): string {
64
    return STATUS_LETTER[this.status];
65
  }
66
67
  get color(): vscode.ThemeColor {
68
    return new vscode.ThemeColor(STATUS_COLOR[this.status]);
69
  }
70
}
71
72
export class SubrepoState implements vscode.SourceControlResourceState {
73
  readonly resourceUri: vscode.Uri;
74
  readonly decorations: vscode.SourceControlResourceDecorations;
75
  readonly command: vscode.Command;
76
  readonly contextValue = "subrepo";
77
78
  constructor(
79
    readonly repository: Repository,
80
    readonly info: SubrepoInfo,
81
  ) {
82
    this.resourceUri = vscode.Uri.file(join(repository.root, info.path));
83
    const pin = info.pin.kind === "oxy" ? `${info.pin.bookmark} ${info.pin.heads.map((h) => h.slice(0, 8)).join(" ")}` : `git ${info.pin.branch} ${info.pin.sha.slice(0, 10)}`;
84
    const state = !info.actual ? "missing (update --init)" : info.dirty ? "uncommitted changes inside" : info.at_pin ? "at pin" : "differs from pin";
85
    this.decorations = { tooltip: `${pin} · ${state}`, faded: !info.actual, strikeThrough: false };
86
    this.command = { command: "oxygen.openSubrepo", title: "Open", arguments: [this] };
87
  }
88
}
89
90
export class Repository implements vscode.Disposable, vscode.QuickDiffProvider {
91
  readonly sourceControl: vscode.SourceControl;
92
  readonly conflictsGroup: vscode.SourceControlResourceGroup;
93
  readonly changesGroup: vscode.SourceControlResourceGroup;
94
  readonly subreposGroup: vscode.SourceControlResourceGroup;
95
  private subreposLatest: SubrepoInfo[] = [];
96
  private readonly statusBar: vscode.StatusBarItem;
97
  private readonly syncBar: vscode.StatusBarItem;
98
  private bookmarksLatest: BookmarkInfo[] = [];
99
  private readonly disposables: vscode.Disposable[] = [];
100
  private readonly onDidChangeStatusEmitter = new vscode.EventEmitter<StatusInfo>();
101
  readonly onDidChangeStatus = this.onDidChangeStatusEmitter.event;
102
  private latest: StatusInfo | undefined;
103
  private refreshTimer: NodeJS.Timeout | undefined;
104
  private refreshing: Promise<void> | undefined;
105
  private refreshAgain = false;
106
107
  constructor(
108
    readonly oxy: Oxy,
109
    readonly root: string,
110
    readonly workspaceFolder: vscode.WorkspaceFolder | undefined,
111
  ) {
112
    const rootUri = vscode.Uri.file(root);
113
    this.sourceControl = vscode.scm.createSourceControl("oxygen", `Oxygen (${basename(root)})`, rootUri);
114
    this.sourceControl.quickDiffProvider = this;
115
    this.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: "Commit", arguments: [this] };
116
    this.sourceControl.inputBox.placeholder = "Message (Ctrl+Enter to commit)";
117
    this.conflictsGroup = this.sourceControl.createResourceGroup("conflicts", "Conflicts");
118
    this.conflictsGroup.hideWhenEmpty = true;
119
    this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes");
120
    this.subreposGroup = this.sourceControl.createResourceGroup("subrepos", "Subrepos");
121
    this.subreposGroup.hideWhenEmpty = true;
122
    this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100);
123
    this.statusBar.name = "Oxygen";
124
    this.statusBar.command = { command: "oxygen.switchBookmark", title: "Switch bookmark", arguments: [this] };
125
    this.syncBar = vscode.window.createStatusBarItem("oxygen.sync", vscode.StatusBarAlignment.Left, 99);
126
    this.syncBar.name = "Oxygen sync";
127
    this.syncBar.command = { command: "oxygen.sync", title: "Sync", arguments: [this] };
128
    this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.subreposGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter);
129
130
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("autoRefresh", true)) {
131
      // Files in the workspace, except any repository store: this one's and
132
      // a subrepo's, which oxy itself writes while reporting status.
133
      const files = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, "**/*"));
134
      const onFile = (uri: vscode.Uri) => {
135
        if (!isInStore(relative(root, uri.fsPath))) {
136
          this.scheduleRefresh();
137
        }
138
      };
139
      files.onDidChange(onFile, this, this.disposables);
140
      files.onDidCreate(onFile, this, this.disposables);
141
      files.onDidDelete(onFile, this, this.disposables);
142
      this.disposables.push(files);
143
      // Any operation, including ones run from a terminal, moves the op head.
144
      const ops = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, ".oxy/ops/head"));
145
      ops.onDidChange(() => this.scheduleRefresh(), this, this.disposables);
146
      ops.onDidCreate(() => this.scheduleRefresh(), this, this.disposables);
147
      this.disposables.push(ops);
148
    }
149
    vscode.window.onDidChangeWindowState((s) => s.focused && this.scheduleRefresh(), this, this.disposables);
150
  }
151
152
  get status(): StatusInfo | undefined {
153
    return this.latest;
154
  }
155
156
  get bookmarks(): BookmarkInfo[] {
157
    return this.bookmarksLatest;
158
  }
159
160
  get subrepos(): SubrepoInfo[] {
161
    return this.subreposLatest;
162
  }
163
164
  get currentBookmark(): BookmarkInfo | undefined {
165
    return this.bookmarksLatest.find((b) => b.current);
166
  }
167
168
  /** The repository-relative path of a file under this root, or undefined. */
169
  relativePath(uri: vscode.Uri): string | undefined {
170
    if (uri.scheme !== "file") {
171
      return undefined;
172
    }
173
    const rel = relative(this.root, uri.fsPath);
174
    if (!rel || rel.startsWith("..") || rel.startsWith(".oxy")) {
175
      return undefined;
176
    }
177
    return rel.split("\\").join("/");
178
  }
179
180
  scheduleRefresh(): void {
181
    if (this.refreshTimer) {
182
      clearTimeout(this.refreshTimer);
183
    }
184
    this.refreshTimer = setTimeout(() => void this.refresh(), 250);
185
  }
186
187
  /** Refresh now; a refresh requested during one runs once more afterwards. */
188
  refresh(): Promise<void> {
189
    if (this.refreshing) {
190
      this.refreshAgain = true;
191
      return this.refreshing;
192
    }
193
    this.refreshing = this.doRefresh().finally(() => {
194
      this.refreshing = undefined;
195
      if (this.refreshAgain) {
196
        this.refreshAgain = false;
197
        void this.refresh();
198
      }
199
    });
200
    return this.refreshing;
201
  }
202
203
  private async doRefresh(): Promise<void> {
204
    try {
205
      const [status, prompt, bookmarks, subrepos] = await Promise.all([
206
        this.oxy.status(this.root),
207
        this.oxy.prompt(this.root),
208
        this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[]),
209
        this.oxy.submodules(this.root).catch(() => [] as SubrepoInfo[]),
210
      ]);
211
      this.latest = status;
212
      this.bookmarksLatest = bookmarks;
213
      this.subreposLatest = subrepos;
214
      this.subreposGroup.resourceStates = subrepos.map((s) => new SubrepoState(this, s));
215
      const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p));
216
      const conflicts: OxyResourceState[] = [];
217
      const changes: OxyResourceState[] = [];
218
      for (const p of status.paths) {
219
        const state = new OxyResourceState(this, p.path, p.status);
220
        if (p.status === "ConflictEdited" || p.status === "Resolved" || conflicted.has(p.path)) {
221
          conflicts.push(state);
222
        } else {
223
          changes.push(state);
224
        }
225
      }
226
      // Conflicted files the working copy has not touched yet still need resolving.
227
      for (const c of status.conflicts) {
228
        if (c.path && !status.paths.some((p) => p.path === c.path)) {
229
          conflicts.push(new OxyResourceState(this, c.path, "ConflictEdited"));
230
        }
231
      }
232
      this.conflictsGroup.resourceStates = conflicts;
233
      this.changesGroup.resourceStates = changes;
234
      this.sourceControl.count = status.paths.length + (status.conflicts.length ? status.conflicts.length : 0);
235
      this.updateStatusBar(status, prompt);
236
      this.onDidChangeStatusEmitter.fire(status);
237
    } catch (e) {
238
      this.statusBar.text = "$(warning) oxy";
239
      this.statusBar.tooltip = String(e);
240
      this.statusBar.show();
241
    }
242
  }
243
244
  private updateStatusBar(status: StatusInfo, prompt: string): void {
245
    if (!vscode.workspace.getConfiguration("oxygen").get<boolean>("statusBar.enabled", true)) {
246
      this.statusBar.hide();
247
      return;
248
    }
249
    const text = prompt || `${status.bookmark} ${status.working_copy.slice(0, 8)}`;
250
    // The bookmark in the Oxygen accent when clean, amber with uncommitted
251
    // work, and on the error background with conflicts.
252
    const dirty = status.paths.length > 0;
253
    this.statusBar.text = `$(${status.conflicted ? "warning" : "bookmark"}) ${text}`;
254
    this.statusBar.color = status.conflicted ? undefined : new vscode.ThemeColor(dirty ? "oxygen.warning" : "oxygen.accent");
255
    this.statusBar.tooltip = new vscode.MarkdownString(
256
      `**Oxygen** ${basename(this.root)}\n\n` +
257
        `Bookmark \`${status.bookmark}\` with ${status.heads} head${status.heads === 1 ? "" : "s"}\n\n` +
258
        (status.conflicted ? `$(warning) The state has conflicts\n\n` : "") +
259
        `${status.paths.length} changed path${status.paths.length === 1 ? "" : "s"}`,
260
      true,
261
    );
262
    this.statusBar.backgroundColor = status.conflicted ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined;
263
    this.statusBar.show();
264
    const current = this.currentBookmark;
265
    if (current && (current.ahead !== null || current.behind !== null)) {
266
      const ahead = current.ahead ?? 0;
267
      const behind = current.behind ?? 0;
268
      this.syncBar.text = `$(cloud-upload) ${ahead} $(cloud-download) ${behind}`;
269
      this.syncBar.color = new vscode.ThemeColor(behind > 0 ? "oxygen.pull" : ahead > 0 ? "oxygen.push" : "oxygen.good");
270
      this.syncBar.tooltip = `${ahead} change${ahead === 1 ? "" : "s"} to push, ${behind} to pull from origin. Click to sync.`;
271
      this.syncBar.show();
272
    } else {
273
      this.syncBar.hide();
274
    }
275
  }
276
277
  // QuickDiffProvider: the base version of a file, served from oxy.
278
  provideOriginalResource(uri: vscode.Uri): vscode.Uri | undefined {
279
    const rel = this.relativePath(uri);
280
    if (!rel) {
281
      return undefined;
282
    }
283
    const status = this.latest?.paths.find((p) => p.path === rel)?.status;
284
    if (status === "Added") {
285
      return undefined;
286
    }
287
    return baseUri(this.root, rel);
288
  }
289
290
  dispose(): void {
291
    if (this.refreshTimer) {
292
      clearTimeout(this.refreshTimer);
293
    }
294
    for (const d of this.disposables) {
295
      d.dispose();
296
    }
297
  }
298
}
299