OXYGENHUB
# 810a1c538e78 oxygen-vscode / src / repository.ts
8.7 KiB · last changed Milestone 0.1: source control view, diffs over the oxy: scheme, quick diff, decorations, status bar, commit, quick-pick log; process wrapper with a Node test; packaging 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 { Oxy, PathStatus, StatusInfo } from "./oxy";
8
import { baseUri } from "./contentProvider";
9
10
const STATUS_LETTER: Record<PathStatus, string> = {
11
  Added: "A",
12
  Modified: "M",
13
  Removed: "D",
14
  Renamed: "R",
15
  ModeChanged: "X",
16
  ConflictEdited: "‼",
17
  Resolved: "✔",
18
};
19
20
const STATUS_WORD: Record<PathStatus, string> = {
21
  Added: "added",
22
  Modified: "modified",
23
  Removed: "removed",
24
  Renamed: "renamed",
25
  ModeChanged: "mode changed",
26
  ConflictEdited: "conflict markers remain",
27
  Resolved: "conflict resolved",
28
};
29
30
const STATUS_COLOR: Record<PathStatus, string> = {
31
  Added: "gitDecoration.addedResourceForeground",
32
  Modified: "gitDecoration.modifiedResourceForeground",
33
  Removed: "gitDecoration.deletedResourceForeground",
34
  Renamed: "gitDecoration.renamedResourceForeground",
35
  ModeChanged: "gitDecoration.modifiedResourceForeground",
36
  ConflictEdited: "gitDecoration.conflictingResourceForeground",
37
  Resolved: "gitDecoration.stageModifiedResourceForeground",
38
};
39
40
export class OxyResourceState implements vscode.SourceControlResourceState {
41
  readonly resourceUri: vscode.Uri;
42
  readonly decorations: vscode.SourceControlResourceDecorations;
43
  readonly command: vscode.Command;
44
45
  constructor(
46
    readonly repository: Repository,
47
    readonly path: string,
48
    readonly status: PathStatus,
49
  ) {
50
    this.resourceUri = vscode.Uri.file(join(repository.root, path));
51
    this.decorations = {
52
      strikeThrough: status === "Removed",
53
      tooltip: STATUS_WORD[status],
54
      faded: false,
55
    };
56
    this.command = { command: "oxygen.openChanges", title: "Open Changes", arguments: [this] };
57
  }
58
59
  get letter(): string {
60
    return STATUS_LETTER[this.status];
61
  }
62
63
  get color(): vscode.ThemeColor {
64
    return new vscode.ThemeColor(STATUS_COLOR[this.status]);
65
  }
66
}
67
68
export class Repository implements vscode.Disposable, vscode.QuickDiffProvider {
69
  readonly sourceControl: vscode.SourceControl;
70
  readonly conflictsGroup: vscode.SourceControlResourceGroup;
71
  readonly changesGroup: vscode.SourceControlResourceGroup;
72
  private readonly statusBar: vscode.StatusBarItem;
73
  private readonly disposables: vscode.Disposable[] = [];
74
  private readonly onDidChangeStatusEmitter = new vscode.EventEmitter<StatusInfo>();
75
  readonly onDidChangeStatus = this.onDidChangeStatusEmitter.event;
76
  private latest: StatusInfo | undefined;
77
  private refreshTimer: NodeJS.Timeout | undefined;
78
  private refreshing: Promise<void> | undefined;
79
  private refreshAgain = false;
80
81
  constructor(
82
    readonly oxy: Oxy,
83
    readonly root: string,
84
    readonly workspaceFolder: vscode.WorkspaceFolder | undefined,
85
  ) {
86
    const rootUri = vscode.Uri.file(root);
87
    this.sourceControl = vscode.scm.createSourceControl("oxygen", `Oxygen (${basename(root)})`, rootUri);
88
    this.sourceControl.quickDiffProvider = this;
89
    this.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: "Commit", arguments: [this] };
90
    this.sourceControl.inputBox.placeholder = "Message (Ctrl+Enter to commit)";
91
    this.conflictsGroup = this.sourceControl.createResourceGroup("conflicts", "Conflicts");
92
    this.conflictsGroup.hideWhenEmpty = true;
93
    this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes");
94
    this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100);
95
    this.statusBar.name = "Oxygen";
96
    this.statusBar.command = "oxygen.showLog";
97
    this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.statusBar, this.onDidChangeStatusEmitter);
98
99
    if (vscode.workspace.getConfiguration("oxygen").get<boolean>("autoRefresh", true)) {
100
      // Files in the workspace, except the repository's own store.
101
      const files = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, "**/*"));
102
      const onFile = (uri: vscode.Uri) => {
103
        if (!uri.fsPath.startsWith(join(root, ".oxy"))) {
104
          this.scheduleRefresh();
105
        }
106
      };
107
      files.onDidChange(onFile, this, this.disposables);
108
      files.onDidCreate(onFile, this, this.disposables);
109
      files.onDidDelete(onFile, this, this.disposables);
110
      this.disposables.push(files);
111
      // Any operation, including ones run from a terminal, moves the op head.
112
      const ops = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, ".oxy/ops/head"));
113
      ops.onDidChange(() => this.scheduleRefresh(), this, this.disposables);
114
      ops.onDidCreate(() => this.scheduleRefresh(), this, this.disposables);
115
      this.disposables.push(ops);
116
    }
117
    vscode.window.onDidChangeWindowState((s) => s.focused && this.scheduleRefresh(), this, this.disposables);
118
  }
119
120
  get status(): StatusInfo | undefined {
121
    return this.latest;
122
  }
123
124
  /** The repository-relative path of a file under this root, or undefined. */
125
  relativePath(uri: vscode.Uri): string | undefined {
126
    if (uri.scheme !== "file") {
127
      return undefined;
128
    }
129
    const rel = relative(this.root, uri.fsPath);
130
    if (!rel || rel.startsWith("..") || rel.startsWith(".oxy")) {
131
      return undefined;
132
    }
133
    return rel.split("\\").join("/");
134
  }
135
136
  scheduleRefresh(): void {
137
    if (this.refreshTimer) {
138
      clearTimeout(this.refreshTimer);
139
    }
140
    this.refreshTimer = setTimeout(() => void this.refresh(), 250);
141
  }
142
143
  /** Refresh now; a refresh requested during one runs once more afterwards. */
144
  refresh(): Promise<void> {
145
    if (this.refreshing) {
146
      this.refreshAgain = true;
147
      return this.refreshing;
148
    }
149
    this.refreshing = this.doRefresh().finally(() => {
150
      this.refreshing = undefined;
151
      if (this.refreshAgain) {
152
        this.refreshAgain = false;
153
        void this.refresh();
154
      }
155
    });
156
    return this.refreshing;
157
  }
158
159
  private async doRefresh(): Promise<void> {
160
    try {
161
      const [status, prompt] = await Promise.all([this.oxy.status(this.root), this.oxy.prompt(this.root)]);
162
      this.latest = status;
163
      const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p));
164
      const conflicts: OxyResourceState[] = [];
165
      const changes: OxyResourceState[] = [];
166
      for (const p of status.paths) {
167
        const state = new OxyResourceState(this, p.path, p.status);
168
        if (p.status === "ConflictEdited" || p.status === "Resolved" || conflicted.has(p.path)) {
169
          conflicts.push(state);
170
        } else {
171
          changes.push(state);
172
        }
173
      }
174
      // Conflicted files the working copy has not touched yet still need resolving.
175
      for (const c of status.conflicts) {
176
        if (c.path && !status.paths.some((p) => p.path === c.path)) {
177
          conflicts.push(new OxyResourceState(this, c.path, "ConflictEdited"));
178
        }
179
      }
180
      this.conflictsGroup.resourceStates = conflicts;
181
      this.changesGroup.resourceStates = changes;
182
      this.sourceControl.count = status.paths.length + (status.conflicts.length ? status.conflicts.length : 0);
183
      this.updateStatusBar(status, prompt);
184
      this.onDidChangeStatusEmitter.fire(status);
185
    } catch (e) {
186
      this.statusBar.text = "$(warning) oxy";
187
      this.statusBar.tooltip = String(e);
188
      this.statusBar.show();
189
    }
190
  }
191
192
  private updateStatusBar(status: StatusInfo, prompt: string): void {
193
    if (!vscode.workspace.getConfiguration("oxygen").get<boolean>("statusBar.enabled", true)) {
194
      this.statusBar.hide();
195
      return;
196
    }
197
    const text = prompt || `${status.bookmark} ${status.working_copy.slice(0, 8)}`;
198
    this.statusBar.text = text;
199
    this.statusBar.tooltip = new vscode.MarkdownString(
200
      `**Oxygen** ${basename(this.root)}\n\n` +
201
        `Bookmark \`${status.bookmark}\` with ${status.heads} head${status.heads === 1 ? "" : "s"}\n\n` +
202
        (status.conflicted ? `$(warning) The state has conflicts\n\n` : "") +
203
        `${status.paths.length} changed path${status.paths.length === 1 ? "" : "s"}`,
204
      true,
205
    );
206
    this.statusBar.backgroundColor = status.conflicted ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined;
207
    this.statusBar.show();
208
  }
209
210
  // QuickDiffProvider: the base version of a file, served from oxy.
211
  provideOriginalResource(uri: vscode.Uri): vscode.Uri | undefined {
212
    const rel = this.relativePath(uri);
213
    if (!rel) {
214
      return undefined;
215
    }
216
    const status = this.latest?.paths.find((p) => p.path === rel)?.status;
217
    if (status === "Added") {
218
      return undefined;
219
    }
220
    return baseUri(this.root, rel);
221
  }
222
223
  dispose(): void {
224
    if (this.refreshTimer) {
225
      clearTimeout(this.refreshTimer);
226
    }
227
    for (const d of this.disposables) {
228
      d.dispose();
229
    }
230
  }
231
}