| 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 { BookmarkInfo, 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 syncBar: vscode.StatusBarItem; |
| 74 |
private bookmarksLatest: BookmarkInfo[] = []; |
| 75 |
private readonly disposables: vscode.Disposable[] = []; |
| 76 |
private readonly onDidChangeStatusEmitter = new vscode.EventEmitter<StatusInfo>(); |
| 77 |
readonly onDidChangeStatus = this.onDidChangeStatusEmitter.event; |
| 78 |
private latest: StatusInfo | undefined; |
| 79 |
private refreshTimer: NodeJS.Timeout | undefined; |
| 80 |
private refreshing: Promise<void> | undefined; |
| 81 |
private refreshAgain = false; |
| 82 |
|
| 83 |
constructor( |
| 84 |
readonly oxy: Oxy, |
| 85 |
readonly root: string, |
| 86 |
readonly workspaceFolder: vscode.WorkspaceFolder | undefined, |
| 87 |
) { |
| 88 |
const rootUri = vscode.Uri.file(root); |
| 89 |
this.sourceControl = vscode.scm.createSourceControl("oxygen", `Oxygen (${basename(root)})`, rootUri); |
| 90 |
this.sourceControl.quickDiffProvider = this; |
| 91 |
this.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: "Commit", arguments: [this] }; |
| 92 |
this.sourceControl.inputBox.placeholder = "Message (Ctrl+Enter to commit)"; |
| 93 |
this.conflictsGroup = this.sourceControl.createResourceGroup("conflicts", "Conflicts"); |
| 94 |
this.conflictsGroup.hideWhenEmpty = true; |
| 95 |
this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes"); |
| 96 |
this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100); |
| 97 |
this.statusBar.name = "Oxygen"; |
| 98 |
this.statusBar.command = { command: "oxygen.switchBookmark", title: "Switch bookmark", arguments: [this] }; |
| 99 |
this.syncBar = vscode.window.createStatusBarItem("oxygen.sync", vscode.StatusBarAlignment.Left, 99); |
| 100 |
this.syncBar.name = "Oxygen sync"; |
| 101 |
this.syncBar.command = { command: "oxygen.sync", title: "Sync", arguments: [this] }; |
| 102 |
this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter); |
| 103 |
|
| 104 |
if (vscode.workspace.getConfiguration("oxygen").get<boolean>("autoRefresh", true)) { |
| 105 |
// Files in the workspace, except the repository's own store. |
| 106 |
const files = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, "**/*")); |
| 107 |
const onFile = (uri: vscode.Uri) => { |
| 108 |
if (!uri.fsPath.startsWith(join(root, ".oxy"))) { |
| 109 |
this.scheduleRefresh(); |
| 110 |
} |
| 111 |
}; |
| 112 |
files.onDidChange(onFile, this, this.disposables); |
| 113 |
files.onDidCreate(onFile, this, this.disposables); |
| 114 |
files.onDidDelete(onFile, this, this.disposables); |
| 115 |
this.disposables.push(files); |
| 116 |
// Any operation, including ones run from a terminal, moves the op head. |
| 117 |
const ops = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, ".oxy/ops/head")); |
| 118 |
ops.onDidChange(() => this.scheduleRefresh(), this, this.disposables); |
| 119 |
ops.onDidCreate(() => this.scheduleRefresh(), this, this.disposables); |
| 120 |
this.disposables.push(ops); |
| 121 |
} |
| 122 |
vscode.window.onDidChangeWindowState((s) => s.focused && this.scheduleRefresh(), this, this.disposables); |
| 123 |
} |
| 124 |
|
| 125 |
get status(): StatusInfo | undefined { |
| 126 |
return this.latest; |
| 127 |
} |
| 128 |
|
| 129 |
get bookmarks(): BookmarkInfo[] { |
| 130 |
return this.bookmarksLatest; |
| 131 |
} |
| 132 |
|
| 133 |
get currentBookmark(): BookmarkInfo | undefined { |
| 134 |
return this.bookmarksLatest.find((b) => b.current); |
| 135 |
} |
| 136 |
|
| 137 |
/** The repository-relative path of a file under this root, or undefined. */ |
| 138 |
relativePath(uri: vscode.Uri): string | undefined { |
| 139 |
if (uri.scheme !== "file") { |
| 140 |
return undefined; |
| 141 |
} |
| 142 |
const rel = relative(this.root, uri.fsPath); |
| 143 |
if (!rel || rel.startsWith("..") || rel.startsWith(".oxy")) { |
| 144 |
return undefined; |
| 145 |
} |
| 146 |
return rel.split("\\").join("/"); |
| 147 |
} |
| 148 |
|
| 149 |
scheduleRefresh(): void { |
| 150 |
if (this.refreshTimer) { |
| 151 |
clearTimeout(this.refreshTimer); |
| 152 |
} |
| 153 |
this.refreshTimer = setTimeout(() => void this.refresh(), 250); |
| 154 |
} |
| 155 |
|
| 156 |
/** Refresh now; a refresh requested during one runs once more afterwards. */ |
| 157 |
refresh(): Promise<void> { |
| 158 |
if (this.refreshing) { |
| 159 |
this.refreshAgain = true; |
| 160 |
return this.refreshing; |
| 161 |
} |
| 162 |
this.refreshing = this.doRefresh().finally(() => { |
| 163 |
this.refreshing = undefined; |
| 164 |
if (this.refreshAgain) { |
| 165 |
this.refreshAgain = false; |
| 166 |
void this.refresh(); |
| 167 |
} |
| 168 |
}); |
| 169 |
return this.refreshing; |
| 170 |
} |
| 171 |
|
| 172 |
private async doRefresh(): Promise<void> { |
| 173 |
try { |
| 174 |
const [status, prompt, bookmarks] = await Promise.all([this.oxy.status(this.root), this.oxy.prompt(this.root), this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[])]); |
| 175 |
this.latest = status; |
| 176 |
this.bookmarksLatest = bookmarks; |
| 177 |
const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p)); |
| 178 |
const conflicts: OxyResourceState[] = []; |
| 179 |
const changes: OxyResourceState[] = []; |
| 180 |
for (const p of status.paths) { |
| 181 |
const state = new OxyResourceState(this, p.path, p.status); |
| 182 |
if (p.status === "ConflictEdited" || p.status === "Resolved" || conflicted.has(p.path)) { |
| 183 |
conflicts.push(state); |
| 184 |
} else { |
| 185 |
changes.push(state); |
| 186 |
} |
| 187 |
} |
| 188 |
// Conflicted files the working copy has not touched yet still need resolving. |
| 189 |
for (const c of status.conflicts) { |
| 190 |
if (c.path && !status.paths.some((p) => p.path === c.path)) { |
| 191 |
conflicts.push(new OxyResourceState(this, c.path, "ConflictEdited")); |
| 192 |
} |
| 193 |
} |
| 194 |
this.conflictsGroup.resourceStates = conflicts; |
| 195 |
this.changesGroup.resourceStates = changes; |
| 196 |
this.sourceControl.count = status.paths.length + (status.conflicts.length ? status.conflicts.length : 0); |
| 197 |
this.updateStatusBar(status, prompt); |
| 198 |
this.onDidChangeStatusEmitter.fire(status); |
| 199 |
} catch (e) { |
| 200 |
this.statusBar.text = "$(warning) oxy"; |
| 201 |
this.statusBar.tooltip = String(e); |
| 202 |
this.statusBar.show(); |
| 203 |
} |
| 204 |
} |
| 205 |
|
| 206 |
private updateStatusBar(status: StatusInfo, prompt: string): void { |
| 207 |
if (!vscode.workspace.getConfiguration("oxygen").get<boolean>("statusBar.enabled", true)) { |
| 208 |
this.statusBar.hide(); |
| 209 |
return; |
| 210 |
} |
| 211 |
const text = prompt || `${status.bookmark} ${status.working_copy.slice(0, 8)}`; |
| 212 |
this.statusBar.text = text; |
| 213 |
this.statusBar.tooltip = new vscode.MarkdownString( |
| 214 |
`**Oxygen** ${basename(this.root)}\n\n` + |
| 215 |
`Bookmark \`${status.bookmark}\` with ${status.heads} head${status.heads === 1 ? "" : "s"}\n\n` + |
| 216 |
(status.conflicted ? `$(warning) The state has conflicts\n\n` : "") + |
| 217 |
`${status.paths.length} changed path${status.paths.length === 1 ? "" : "s"}`, |
| 218 |
true, |
| 219 |
); |
| 220 |
this.statusBar.backgroundColor = status.conflicted ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined; |
| 221 |
this.statusBar.show(); |
| 222 |
const current = this.currentBookmark; |
| 223 |
if (current && (current.ahead !== null || current.behind !== null)) { |
| 224 |
const ahead = current.ahead ?? 0; |
| 225 |
const behind = current.behind ?? 0; |
| 226 |
this.syncBar.text = `$(arrow-up)${ahead} $(arrow-down)${behind}`; |
| 227 |
this.syncBar.tooltip = `${ahead} change${ahead === 1 ? "" : "s"} to push, ${behind} to pull from origin. Click to sync.`; |
| 228 |
this.syncBar.show(); |
| 229 |
} else { |
| 230 |
this.syncBar.hide(); |
| 231 |
} |
| 232 |
} |
| 233 |
|
| 234 |
// QuickDiffProvider: the base version of a file, served from oxy. |
| 235 |
provideOriginalResource(uri: vscode.Uri): vscode.Uri | undefined { |
| 236 |
const rel = this.relativePath(uri); |
| 237 |
if (!rel) { |
| 238 |
return undefined; |
| 239 |
} |
| 240 |
const status = this.latest?.paths.find((p) => p.path === rel)?.status; |
| 241 |
if (status === "Added") { |
| 242 |
return undefined; |
| 243 |
} |
| 244 |
return baseUri(this.root, rel); |
| 245 |
} |
| 246 |
|
| 247 |
dispose(): void { |
| 248 |
if (this.refreshTimer) { |
| 249 |
clearTimeout(this.refreshTimer); |
| 250 |
} |
| 251 |
for (const d of this.disposables) { |
| 252 |
d.dispose(); |
| 253 |
} |
| 254 |
} |
| 255 |
} |