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