// One Oxygen repository as VS Code sees it: the source control provider, // its resource groups, the status bar item, and the watchers that keep // them fresh (spec §3, §5.1, §5.4). import * as vscode from "vscode"; import { basename, join, relative } from "node:path"; import { isInStore } from "./paths"; import { BookmarkInfo, Oxy, PathStatus, StatusInfo, SubrepoInfo } from "./oxy"; import { baseUri } from "./contentProvider"; const STATUS_LETTER: Record = { Added: "A", Modified: "M", Removed: "D", Renamed: "R", ModeChanged: "X", ConflictEdited: "‼", Resolved: "✔", }; const STATUS_WORD: Record = { Added: "added", Modified: "modified", Removed: "removed", Renamed: "renamed", ModeChanged: "mode changed", ConflictEdited: "conflict markers remain", Resolved: "conflict resolved", }; const STATUS_COLOR: Record = { Added: "oxygen.added", Modified: "oxygen.modified", Removed: "oxygen.removed", Renamed: "oxygen.renamed", ModeChanged: "oxygen.modified", ConflictEdited: "oxygen.conflict", Resolved: "oxygen.resolved", }; export class OxyResourceState implements vscode.SourceControlResourceState { readonly resourceUri: vscode.Uri; readonly decorations: vscode.SourceControlResourceDecorations; readonly command: vscode.Command; constructor( readonly repository: Repository, readonly path: string, readonly status: PathStatus, ) { this.resourceUri = vscode.Uri.file(join(repository.root, path)); this.decorations = { strikeThrough: status === "Removed", tooltip: STATUS_WORD[status], faded: false, }; this.command = { command: "oxygen.openChanges", title: "Open Changes", arguments: [this] }; this.contextValue = /\.oxy-conflict-\d+$/.test(path) ? "candidate" : status === "ConflictEdited" ? "markers" : status; } contextValue: string; get letter(): string { return STATUS_LETTER[this.status]; } get color(): vscode.ThemeColor { return new vscode.ThemeColor(STATUS_COLOR[this.status]); } } export class SubrepoState implements vscode.SourceControlResourceState { readonly resourceUri: vscode.Uri; readonly decorations: vscode.SourceControlResourceDecorations; readonly command: vscode.Command; readonly contextValue = "subrepo"; constructor( readonly repository: Repository, readonly info: SubrepoInfo, ) { this.resourceUri = vscode.Uri.file(join(repository.root, info.path)); 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)}`; const state = !info.actual ? "missing (update --init)" : info.dirty ? "uncommitted changes inside" : info.at_pin ? "at pin" : "differs from pin"; this.decorations = { tooltip: `${pin} · ${state}`, faded: !info.actual, strikeThrough: false }; this.command = { command: "oxygen.openSubrepo", title: "Open", arguments: [this] }; } } export class Repository implements vscode.Disposable, vscode.QuickDiffProvider { readonly sourceControl: vscode.SourceControl; readonly conflictsGroup: vscode.SourceControlResourceGroup; readonly changesGroup: vscode.SourceControlResourceGroup; readonly subreposGroup: vscode.SourceControlResourceGroup; private subreposLatest: SubrepoInfo[] = []; private readonly statusBar: vscode.StatusBarItem; private readonly syncBar: vscode.StatusBarItem; private bookmarksLatest: BookmarkInfo[] = []; private readonly disposables: vscode.Disposable[] = []; private readonly onDidChangeStatusEmitter = new vscode.EventEmitter(); readonly onDidChangeStatus = this.onDidChangeStatusEmitter.event; private latest: StatusInfo | undefined; private refreshTimer: NodeJS.Timeout | undefined; private refreshing: Promise | undefined; private refreshAgain = false; constructor( readonly oxy: Oxy, readonly root: string, readonly workspaceFolder: vscode.WorkspaceFolder | undefined, ) { const rootUri = vscode.Uri.file(root); this.sourceControl = vscode.scm.createSourceControl("oxygen", `Oxygen (${basename(root)})`, rootUri); this.sourceControl.quickDiffProvider = this; this.sourceControl.acceptInputCommand = { command: "oxygen.commit", title: "Commit", arguments: [this] }; this.sourceControl.inputBox.placeholder = "Message (Ctrl+Enter to commit)"; this.conflictsGroup = this.sourceControl.createResourceGroup("conflicts", "Conflicts"); this.conflictsGroup.hideWhenEmpty = true; this.changesGroup = this.sourceControl.createResourceGroup("changes", "Changes"); this.subreposGroup = this.sourceControl.createResourceGroup("subrepos", "Subrepos"); this.subreposGroup.hideWhenEmpty = true; this.statusBar = vscode.window.createStatusBarItem("oxygen.status", vscode.StatusBarAlignment.Left, 100); this.statusBar.name = "Oxygen"; this.statusBar.command = { command: "oxygen.switchBookmark", title: "Switch bookmark", arguments: [this] }; this.syncBar = vscode.window.createStatusBarItem("oxygen.sync", vscode.StatusBarAlignment.Left, 99); this.syncBar.name = "Oxygen sync"; this.syncBar.command = { command: "oxygen.sync", title: "Sync", arguments: [this] }; this.disposables.push(this.sourceControl, this.conflictsGroup, this.changesGroup, this.subreposGroup, this.statusBar, this.syncBar, this.onDidChangeStatusEmitter); if (vscode.workspace.getConfiguration("oxygen").get("autoRefresh", true)) { // Files in the workspace, except any repository store: this one's and // a subrepo's, which oxy itself writes while reporting status. const files = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, "**/*")); const onFile = (uri: vscode.Uri) => { if (!isInStore(relative(root, uri.fsPath))) { this.scheduleRefresh(); } }; files.onDidChange(onFile, this, this.disposables); files.onDidCreate(onFile, this, this.disposables); files.onDidDelete(onFile, this, this.disposables); this.disposables.push(files); // Any operation, including ones run from a terminal, moves the op head. const ops = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(rootUri, ".oxy/ops/head")); ops.onDidChange(() => this.scheduleRefresh(), this, this.disposables); ops.onDidCreate(() => this.scheduleRefresh(), this, this.disposables); this.disposables.push(ops); } vscode.window.onDidChangeWindowState((s) => s.focused && this.scheduleRefresh(), this, this.disposables); } get status(): StatusInfo | undefined { return this.latest; } get bookmarks(): BookmarkInfo[] { return this.bookmarksLatest; } get subrepos(): SubrepoInfo[] { return this.subreposLatest; } get currentBookmark(): BookmarkInfo | undefined { return this.bookmarksLatest.find((b) => b.current); } /** The repository-relative path of a file under this root, or undefined. */ relativePath(uri: vscode.Uri): string | undefined { if (uri.scheme !== "file") { return undefined; } const rel = relative(this.root, uri.fsPath); if (!rel || rel.startsWith("..") || rel.startsWith(".oxy")) { return undefined; } return rel.split("\\").join("/"); } scheduleRefresh(): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); } this.refreshTimer = setTimeout(() => void this.refresh(), 250); } /** Refresh now; a refresh requested during one runs once more afterwards. */ refresh(): Promise { if (this.refreshing) { this.refreshAgain = true; return this.refreshing; } this.refreshing = this.doRefresh().finally(() => { this.refreshing = undefined; if (this.refreshAgain) { this.refreshAgain = false; void this.refresh(); } }); return this.refreshing; } private async doRefresh(): Promise { try { const [status, prompt, bookmarks, subrepos] = await Promise.all([ this.oxy.status(this.root), this.oxy.prompt(this.root), this.oxy.bookmarks(this.root).catch(() => [] as BookmarkInfo[]), this.oxy.submodules(this.root).catch(() => [] as SubrepoInfo[]), ]); this.latest = status; this.bookmarksLatest = bookmarks; this.subreposLatest = subrepos; this.subreposGroup.resourceStates = subrepos.map((s) => new SubrepoState(this, s)); const conflicted = new Set(status.conflicts.map((c) => c.path).filter((p): p is string => !!p)); const conflicts: OxyResourceState[] = []; const changes: OxyResourceState[] = []; for (const p of status.paths) { const state = new OxyResourceState(this, p.path, p.status); if (p.status === "ConflictEdited" || p.status === "Resolved" || conflicted.has(p.path)) { conflicts.push(state); } else { changes.push(state); } } // Conflicted files the working copy has not touched yet still need resolving. for (const c of status.conflicts) { if (c.path && !status.paths.some((p) => p.path === c.path)) { conflicts.push(new OxyResourceState(this, c.path, "ConflictEdited")); } } this.conflictsGroup.resourceStates = conflicts; this.changesGroup.resourceStates = changes; this.sourceControl.count = status.paths.length + (status.conflicts.length ? status.conflicts.length : 0); this.updateStatusBar(status, prompt); this.onDidChangeStatusEmitter.fire(status); } catch (e) { this.statusBar.text = "$(warning) oxy"; this.statusBar.tooltip = String(e); this.statusBar.show(); } } private updateStatusBar(status: StatusInfo, prompt: string): void { if (!vscode.workspace.getConfiguration("oxygen").get("statusBar.enabled", true)) { this.statusBar.hide(); return; } const text = prompt || `${status.bookmark} ${status.working_copy.slice(0, 8)}`; // The bookmark in the Oxygen accent when clean, amber with uncommitted // work, and on the error background with conflicts. const dirty = status.paths.length > 0; this.statusBar.text = `$(${status.conflicted ? "warning" : "bookmark"}) ${text}`; this.statusBar.color = status.conflicted ? undefined : new vscode.ThemeColor(dirty ? "oxygen.warning" : "oxygen.accent"); this.statusBar.tooltip = new vscode.MarkdownString( `**Oxygen** ${basename(this.root)}\n\n` + `Bookmark \`${status.bookmark}\` with ${status.heads} head${status.heads === 1 ? "" : "s"}\n\n` + (status.conflicted ? `$(warning) The state has conflicts\n\n` : "") + `${status.paths.length} changed path${status.paths.length === 1 ? "" : "s"}`, true, ); this.statusBar.backgroundColor = status.conflicted ? new vscode.ThemeColor("statusBarItem.errorBackground") : undefined; this.statusBar.show(); const current = this.currentBookmark; if (current && (current.ahead !== null || current.behind !== null)) { const ahead = current.ahead ?? 0; const behind = current.behind ?? 0; this.syncBar.text = `$(cloud-upload) ${ahead} $(cloud-download) ${behind}`; this.syncBar.color = new vscode.ThemeColor(behind > 0 ? "oxygen.pull" : ahead > 0 ? "oxygen.push" : "oxygen.good"); this.syncBar.tooltip = `${ahead} change${ahead === 1 ? "" : "s"} to push, ${behind} to pull from origin. Click to sync.`; this.syncBar.show(); } else { this.syncBar.hide(); } } // QuickDiffProvider: the base version of a file, served from oxy. provideOriginalResource(uri: vscode.Uri): vscode.Uri | undefined { const rel = this.relativePath(uri); if (!rel) { return undefined; } const status = this.latest?.paths.find((p) => p.path === rel)?.status; if (status === "Added") { return undefined; } return baseUri(this.root, rel); } dispose(): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); } for (const d of this.disposables) { d.dispose(); } } }