diff --git a/README.md b/README.md index 035b9bf..ab330a9 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ about cloning from the extension. * Specifies an explicit `fossil` file path to use. * This should only be used if `fossil` cannot be found automatically. - * The default behaviour is to search for `fossil` on the PATH. + * The default behavior is to search for `fossil` on the PATH. * Takes effect immediately. `fossil.username { string }` @@ -114,6 +114,10 @@ about cloning from the extension. * Specifies an explicit user to use for fossil commits. * This should only be used if the user is different than the fossil default user. +`fossil.autoSyncInterval { number }` + * The duration, in seconds, between each background `fossil sync` operation. + * 0 to disable. + # Troubleshooting In general, Fossil designers maintain an abundance of diff --git a/docs/dev/api.md b/docs/dev/api.md index e7b6bbc..aa6c3f4 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -58,6 +58,7 @@ _Work in progress_. | fossil.stashPop | Stash Pop | • Main SCM menu
• Command palette | | fossil.stashSave | Stash Push | • Main SCM menu
• Command palette | | fossil.stashSnapshot | Stash Snapshot | • Main SCM menu
• Command palette | +| fossil.sync | Sync | • Main SCM menu
• Command palette | execute `fossil sync` | fossil.undo | Undo | • Main SCM menu
• Command palette | execute `fossil undo` | fossil.unstage | Unstage Changes | | fossil.unstageAll | Unstage All Changes | diff --git a/package.json b/package.json index b8f85f8..dd8e4de 100644 --- a/package.json +++ b/package.json @@ -264,6 +264,11 @@ "category": "Fossil", "icon": "$(plus)" }, + { + "command": "fossil.sync", + "title": "%command.sync%", + "category": "Fossil" + }, { "command": "fossil.undo", "title": "%command.undo%", @@ -571,7 +576,7 @@ }, { "command": "fossil.render", - "when": "fossilOpenRepositoryCount && !isInDiffEditor && resourceExtname =~ /^\\.(md|wiki|pikchr)$/ || fossil.found && resourceScheme == untitled" + "when": "fossil.found && !isInDiffEditor && resourceExtname == '.md' || fossil.found && !isInDiffEditor && resourceExtname =~ /^\\.(md|wiki|pikchr)$/ || fossilOpenRepositoryCount && resourceScheme == untitled" }, { "command": "fossil.deleteFiles", @@ -630,20 +635,25 @@ "when": "scmProvider == fossil" }, { - "command": "fossil.pull", + "command": "fossil.sync", "group": "1_sync@2", "when": "scmProvider == fossil" }, { - "command": "fossil.push", + "command": "fossil.pull", "group": "1_sync@3", "when": "scmProvider == fossil" }, { - "command": "fossil.pushTo", + "command": "fossil.push", "group": "1_sync@4", "when": "scmProvider == fossil" }, + { + "command": "fossil.pushTo", + "group": "1_sync@5", + "when": "scmProvider == fossil" + }, { "command": "fossil.undo", "group": "3_commit@0", @@ -1036,10 +1046,11 @@ "configuration": { "title": "Fossil", "properties": { - "fossil.autoInOutInterval": { + "fossil.autoSyncInterval": { "type": "number", - "description": "%config.autoInOutInterval%", - "default": 180 + "description": "%config.autoSyncInterval%", + "default": 180, + "minimum": 0 }, "fossil.autoRefresh": { "type": "boolean", diff --git a/package.nls.json b/package.nls.json index 8d015da..8fc7715 100644 --- a/package.nls.json +++ b/package.nls.json @@ -19,6 +19,7 @@ "command.commitStaged": "Commit Staged", "command.commitAll": "Commit All", "command.commitBranch": "Commit Creating New Branch...", + "command.sync": "Sync", "command.undo": "Undo", "command.update": "Update", "command.redo": "Redo", @@ -41,11 +42,9 @@ "command.merge": "Merge into working directory...", "command.integrate": "Integrate into working directory...", "command.cherrypick": "Cherry-pick into working directory...", - "config.enabled": "Whether Fossil is enabled", "config.path": "Path to the 'fossil' executable (only required if auto-detection fails)", "config.username": "The username associated with each commit (only required if different from user that originally cloned repo).", - "config.autoInOut": "Whether auto-incoming/outgoing counts are enabled", - "config.autoInOutInterval": "How many seconds between each autoInOut poll", + "config.autoSyncInterval": "The duration, in seconds, between each background `fossil sync` operation. 0 to disable.", "config.autoRefresh": "Whether auto refreshing is enabled", "config.enableRenaming": "Show rename request after a file was renamed in UI", "config.enableLongCommitWarning": "Whether long commit messages should be warned about", diff --git a/src/autoinout.ts b/src/autoinout.ts deleted file mode 100644 index ef6c794..0000000 --- a/src/autoinout.ts +++ /dev/null @@ -1,103 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Ben Crowl. All rights reserved. - * Original Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { workspace, Disposable } from 'vscode'; -import { throttle } from './decorators'; -import typedConfig from './config'; -import { Repository, Operation } from './repository'; - -export const enum AutoInOutStatuses { - Disabled, - Enabled, - Error, -} - -export interface AutoInOutState { - readonly status: AutoInOutStatuses; - readonly nextCheckTime?: Date; - readonly error?: string; -} - -const OPS_AFFECTING_IN_OUT = [ - Operation.Commit, - Operation.RevertFiles, - Operation.Update, - Operation.Push, - Operation.Pull, -]; -const opAffectsInOut = (op: Operation): boolean => - OPS_AFFECTING_IN_OUT.includes(op); - -export class AutoIncomingOutgoing { - private disposables: Disposable[] = []; - private timer: ReturnType | undefined; - - constructor(private repository: Repository) { - workspace.onDidChangeConfiguration( - this.onConfiguration, - this, - this.disposables - ); - this.repository.onDidRunOperation( - this.onDidRunOperation, - this, - this.disposables - ); - this.onConfiguration(); - } - - private onConfiguration(): void { - this.repository.changeAutoInoutState({ - status: AutoInOutStatuses.Enabled, - }); - this.enable(); - } - - enable(): void { - if (this.enabled) { - return; - } - this.refresh(); - this.timer = setInterval( - () => this.refresh(), - typedConfig.autoInOutIntervalMs - ); - } - - disable(): void { - if (!this.enabled) { - return; - } - - clearInterval(this.timer!); - this.timer = undefined; - } - - get enabled(): boolean { - return this.timer !== undefined; - } - - private onDidRunOperation(op: Operation): void { - if (!this.enabled || !opAffectsInOut(op)) { - return; - } - this.repository.changeInoutAfterDelay(); - } - - @throttle - private async refresh(): Promise { - const nextCheckTime = new Date( - Date.now() + typedConfig.autoInOutIntervalMs - ); - this.repository.changeAutoInoutState({ nextCheckTime }); - await this.repository.changeInoutAfterDelay(); - } - - dispose(): void { - this.disable(); - this.disposables.forEach(d => d.dispose()); - } -} diff --git a/src/commands.ts b/src/commands.ts index 2bfa1a1..edfcc01 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -48,7 +48,7 @@ import * as humanise from './humanise'; import { partition } from './util'; import { toFossilUri } from './uri'; import { FossilPreviewManager } from './preview'; -import type { FossilCWD, FossilExecutable, Reason } from './fossilExecutable'; +import type { FossilCWD, FossilExecutable } from './fossilExecutable'; import { localize } from './main'; import type { Credentials } from './gitExport'; @@ -109,6 +109,7 @@ type CommandKey = | 'stashPop' | 'stashSave' | 'stashSnapshot' + | 'sync' | 'undo' | 'unstage' | 'unstageAll' @@ -196,7 +197,7 @@ export class CommandCenter { @command(Inline.Repository) async refresh(repository: Repository): Promise { - await repository.status('forced refresh' as Reason); + await repository.refresh(); } @command() @@ -1118,7 +1119,7 @@ export class CommandCenter { const checkin = await interaction.pickUpdateCheckin(refs); if (checkin) { - repository.update(checkin); + await repository.update(checkin); } } @@ -1404,6 +1405,11 @@ export class CommandCenter { } } + @command(Inline.Repository) + async sync(repository: Repository): Promise { + await repository.sync(); + } + @command() async praise(): Promise { const editor = window.activeTextEditor; diff --git a/src/config.ts b/src/config.ts index 43fdde8..9967bc8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,13 @@ import { workspace } from 'vscode'; -import type { FossilUsername } from './openedRepository'; +import type { FossilUsername, Distinct } from './openedRepository'; import type { UnvalidatedFossilExecutablePath } from './fossilFinder'; +export type AutoSyncIntervalMs = Distinct; + interface ConfigScheme { ignoreMissingFossilWarning: boolean; path: UnvalidatedFossilExecutablePath; - autoInOutInterval: number; + autoSyncInterval: number; username: FossilUsername; // must be ignored when empty autoRefresh: boolean; enableRenaming: boolean; @@ -37,8 +39,8 @@ class Config { return this.get('autoRefresh'); } - get autoInOutIntervalMs(): number { - return this.get('autoInOutInterval') * 1000; + get autoSyncIntervalMs(): AutoSyncIntervalMs { + return (this.get('autoSyncInterval') * 1000) as AutoSyncIntervalMs; } get enableRenaming(): boolean { diff --git a/src/fossilExecutable.ts b/src/fossilExecutable.ts index 4d02d65..7dda1da 100644 --- a/src/fossilExecutable.ts +++ b/src/fossilExecutable.ts @@ -30,8 +30,13 @@ export type FossilExecutablePath = Distinct; export interface FossilSpawnOptions extends cp.SpawnOptionsWithoutStdio { readonly cwd: FossilCWD; - readonly logErrors?: boolean; // whether to log stderr to the fossil outputChannel - readonly stdin_data?: string; // dump data to stdin + /** + * Whether to log stderr to the fossil outputChannel and + * whether to show message box with an error + */ + readonly logErrors?: boolean; + /** Supply data to stdin */ + readonly stdin_data?: string; } interface FossilRawResult { @@ -106,6 +111,7 @@ type FossilCommand = | 'sqlite' | 'stash' | 'status' + | 'sync' | 'tag' | 'test-markdown-render' | 'test-wiki-render' @@ -367,7 +373,9 @@ export class FossilExecutable { })(); if (options.logErrors !== false && result.stderr) { - this.outputChannel.error(result.stderr); + this.outputChannel.error( + `(${args.join(', ')}): ${result.stderr}` + ); } const failure: ExecFailure = { ...result, diff --git a/src/model.ts b/src/model.ts index 119af24..6f4c43e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -179,6 +179,13 @@ export class Model implements Disposable { this.foundExecutable.bind(this) ); } + if (!event || event.affectsConfiguration('fossil.autoSyncInterval')) { + for (const repository of this.repositories) { + repository.updateAutoSyncInterval( + typedConfig.autoSyncIntervalMs + ); + } + } } public async foundExecutable( @@ -343,9 +350,7 @@ export class Model implements Disposable { for (const { oldUri, newUri } of e.files) { const repository = this.getRepository(oldUri); if (repository) { - await repository.updateModelState( - 'file rename event' as Reason - ); + await repository.updateStatus('file rename event' as Reason); if ( repository.isInAnyGroup(oldUri) || repository.isDirInAnyGroup(oldUri) diff --git a/src/openedRepository.ts b/src/openedRepository.ts index 86c7341..036e63f 100644 --- a/src/openedRepository.ts +++ b/src/openedRepository.ts @@ -254,8 +254,20 @@ export class OpenedRepository { return (result.stdout + result.stderr).trim(); } - async update(checkin?: FossilCheckin): Promise { - await this.exec(['update', ...(checkin ? [checkin] : [])]); + async update( + checkin?: FossilCheckin, + dryRun?: true, + reason?: Reason + ): Promise { + return this.exec( + [ + 'update', + ...(checkin ? [checkin] : []), + ...(dryRun ? ['--dry-run', '--latest'] : []), + ], + reason, + { logErrors: !dryRun } + ); } async commit( diff --git a/src/repository.ts b/src/repository.ts index b4d1dbe..6820df1 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -52,8 +52,8 @@ import { delay, } from './util'; import { memoize, throttle, debounce } from './decorators'; -import { StatusBarCommands } from './statusbar'; -import typedConfig from './config'; +import { StatusBarCommands } from './statusBar'; +import typedConfig, { AutoSyncIntervalMs } from './config'; import * as path from 'path'; import { @@ -62,11 +62,6 @@ import { IStatusGroups, groupStatuses, } from './resourceGroups'; -import { - AutoInOutState, - AutoInOutStatuses, - AutoIncomingOutgoing, -} from './autoinout'; import * as interaction from './interaction'; import type { InteractionAPI, NewBranchOptions } from './interaction'; import { FossilUriParams, toFossilUri } from './uri'; @@ -195,46 +190,39 @@ export class FossilResource implements SourceControlResourceState { ) {} } -export const enum Operation { - Add, - Branch, - Clean, - Close, - Commit, - Forget, - Ignore, - Init, - Merge, - PatchApply, - PatchCreate, - Pull, - Push, - Rename, - Resolve, - Revert, - RevertFiles, - Show, - Stage, - Status, - Sync, - Undo, - UndoDryRun, - Update, -} +type SideEffects = { + /** + * Files could change + * Only execute `fossil status` + */ + status?: true; + /** + * Information about remote could change, + * or local "head" has changed + * Only execute `fossil update --dry-run', '--latest` + */ + changes?: true; + /** + * Branch could be changed + * Only execute `fossil branch current` + */ + branch?: true; + /** + * Tooltip text to show in the statusBar. Currently unused. + */ + syncText?: string; +}; -function isReadOnly(operation: Operation): boolean { - return [ - Operation.Show, - // ToDo: make readonly, 'fossil.refresh' doesn't allow it yet... - // Operation.Status - Operation.Stage, - Operation.UndoDryRun, - ].includes(operation); -} +const UpdateStatus: SideEffects = { status: true }; +const UpdateStatusAndBranch: SideEffects = { status: true, branch: true }; +const UpdateAll: SideEffects = { status: true, branch: true, changes: true }; +const UpdateChanges: SideEffects = { changes: true }; export const enum CommitScope { - UNKNOWN, // try STAGING_GROUP, but if none, try WORKING_GROUP - ALL, // don't use file from any group, useful for merge commit + /** try STAGING_GROUP, but if none, try WORKING_GROUP */ + UNKNOWN, + /** don't use file from any group, useful for merge commit */ + ALL, STAGING_GROUP, WORKING_GROUP, } @@ -253,53 +241,52 @@ export class Repository implements IDisposable, InteractionAPI { readonly onDidChangeState: Event = this._onDidChangeState.event; - private _onDidChangeStatus = new EventEmitter(); - readonly onDidChangeStatus: Event = this._onDidChangeStatus.event; - - private _onDidChangeInOutState = new EventEmitter(); - private readonly onDidChangeInOutState: Event = - this._onDidChangeInOutState.event; - + /** + * repository was: + * - disposed + * - files were (un)staged + */ private _onDidChangeResources = new EventEmitter(); private readonly onDidChangeResources: Event = this._onDidChangeResources.event; - @memoize - get onDidChange(): Event { - return anyEvent( - this.onDidChangeState, - this.onDidChangeResources, - this.onDidChangeInOutState - ); - } - private _onDidChangeOriginalResource = new EventEmitter(); readonly onDidChangeOriginalResource: Event = this._onDidChangeOriginalResource.event; - private _onRunOperation = new EventEmitter(); - private readonly onRunOperation: Event = - this._onRunOperation.event; + private _onRunOperation = new EventEmitter(); + private readonly onRunOperation: Event = this._onRunOperation.event; - private _onDidRunOperation = new EventEmitter(); - readonly onDidRunOperation: Event = - this._onDidRunOperation.event; + private _onDidRunOperation = new EventEmitter(); + readonly onDidRunOperation: Event = this._onDidRunOperation.event; private _sourceControl: SourceControl; + private autoSyncTimer: ReturnType | undefined; + + private _currentBranch: FossilBranch | undefined; + private _operations = new Map(); + private _state = RepositoryState.Idle; + private readonly disposables: Disposable[] = []; + private readonly statusBar: StatusBarCommands; + // ToDo: rename and possibly make non optional + private _fossilStatus: FossilStatus | undefined; + private _groups: IStatusGroups; - get sourceControl(): SourceControl { + get sourceControl(): Readonly { return this._sourceControl; } + /** + * An operation started or stopped running + */ @memoize - get onDidChangeOperations(): Event { + private get onDidChangeOperations(): Event { return anyEvent( this.onRunOperation as Event, this.onDidRunOperation as Event ); } - private _groups: IStatusGroups; get conflictGroup(): FossilResourceGroup { return this._groups.conflict; } @@ -313,42 +300,22 @@ export class Repository implements IDisposable, InteractionAPI { return this._groups.untracked; } - private _currentBranch: FossilBranch | undefined; get currentBranch(): FossilBranch | undefined { return this._currentBranch; } - // ToDo: rename and possibly make non optional - private _fossilStatus: FossilStatus | undefined; get fossilStatus(): FossilStatus | undefined { return this._fossilStatus; } - private _operations = new Set(); - get operations(): Set { + get operations(): ReadonlyMap { return this._operations; } - private _autoInOutState: AutoInOutState = { - status: AutoInOutStatuses.Disabled, - }; - get autoInOutState(): AutoInOutState { - return this._autoInOutState; - } - - public changeAutoInoutState(state: Partial): void { - this._autoInOutState = { - ...this._autoInOutState, - ...state, - }; - this._onDidChangeInOutState.fire(); - } - - toUri(rawPath: string): Uri { + toUri(rawPath: RelativePath): Uri { return Uri.file(path.join(this.repository.root, rawPath)); } - private _state = RepositoryState.Idle; get state(): RepositoryState { return this._state; } @@ -368,7 +335,7 @@ export class Repository implements IDisposable, InteractionAPI { return this.repository.root; } - private readonly disposables: Disposable[] = []; + private operations_size: number = 0; constructor(private readonly repository: OpenedRepository) { const repoRootWatcher = workspace.createFileSystemWatcher( @@ -415,20 +382,15 @@ export class Repository implements IDisposable, InteractionAPI { ) ); - const statusBar = new StatusBarCommands(this); - this.disposables.push(statusBar); - statusBar.onDidChange( - () => { - this._sourceControl.statusBarCommands = statusBar.commands; - }, - null, + this.statusBar = new StatusBarCommands(this, this.sourceControl); + this.onDidChangeOperations( + this.statusBar.update, + this.statusBar, this.disposables ); - this._sourceControl.statusBarCommands = statusBar.commands; - - this.updateModelState('opening repository' as Reason); - - this.disposables.push(new AutoIncomingOutgoing(this)); + this.updateModelState(UpdateAll, 'opening repository' as Reason).then( + () => this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs) + ); } provideOriginalResource(uri: Uri): Uri | undefined { @@ -439,11 +401,8 @@ export class Repository implements IDisposable, InteractionAPI { } @throttle - async status(reason: Reason): Promise { - const statusPromise = this.repository.getStatus(reason); - await this.runWithProgress(Operation.Status, () => statusPromise); - this.updateInputBoxPlaceholder(); - return statusPromise; + async refresh(): Promise { + await this.runWithProgress(UpdateAll, () => Promise.resolve()); } private onFSChange(_uri: Uri): void { @@ -451,7 +410,7 @@ export class Repository implements IDisposable, InteractionAPI { return; } - if (this.operations.size !== 0) { + if (this.operations_size !== 0) { return; } @@ -466,7 +425,7 @@ export class Repository implements IDisposable, InteractionAPI { @throttle private async updateWhenIdleAndWait(): Promise { await this.whenIdleAndFocused(); - await this.updateModelState('idle update' as Reason); + await this.updateModelState(UpdateStatus, 'idle update' as Reason); await delay(5000); } @@ -495,7 +454,7 @@ export class Repository implements IDisposable, InteractionAPI { */ async whenIdleAndFocused(): Promise { while (true) { - if (this.operations.size !== 0) { + if (this.operations_size !== 0) { await eventToPromise(this.onDidRunOperation); continue; } @@ -524,7 +483,7 @@ export class Repository implements IDisposable, InteractionAPI { const relativePaths = resources.map(r => this.mapResourceToRepoRelativePath(r) ); - await this.runWithProgress(Operation.Add, () => + await this.runWithProgress(UpdateStatus, () => this.repository.add(relativePaths) ); } @@ -545,7 +504,7 @@ export class Repository implements IDisposable, InteractionAPI { const relativePaths = resources.map(r => this.mapResourceToRepoRelativePath(r) ); - await this.runWithProgress(Operation.Forget, () => + await this.runWithProgress(UpdateStatus, () => this.repository.forget(relativePaths) ); } @@ -554,7 +513,7 @@ export class Repository implements IDisposable, InteractionAPI { oldPath: AnyPath, newPath: RelativePath | UserPath ): Promise { - await this.runWithProgress(Operation.Rename, () => + await this.runWithProgress(UpdateStatus, () => this.repository.rename(oldPath, newPath) ); } @@ -570,7 +529,7 @@ export class Repository implements IDisposable, InteractionAPI { const relativePaths = resources.map(r => this.mapResourceToRepoRelativePath(r) ); - await this.runWithProgress(Operation.Ignore, () => + await this.runWithProgress(UpdateStatus, () => this.repository.ignore(relativePaths) ); } @@ -593,47 +552,50 @@ export class Repository implements IDisposable, InteractionAPI { @throttle async stage(...resourceUris: Uri[]): Promise { - await this.runWithProgress(Operation.Stage, async () => { - let resources = this.mapResources(resourceUris); - - if (resources.length === 0) { - resources = this._groups.working.resourceStates; - } + await this.runWithProgress( + {} /* basic staging does't affect status */, + async () => { + let resources = this.mapResources(resourceUris); - const missingResources = resources.filter( - r => r.status === ResourceStatus.MISSING - ); + if (resources.length === 0) { + resources = this._groups.working.resourceStates; + } - if (missingResources.length) { - const relativePaths = missingResources.map(r => - this.mapResourceToRepoRelativePath(r) - ); - await this.runWithProgress(Operation.Forget, () => - this.repository.forget(relativePaths) + const missingResources = resources.filter( + r => r.status === ResourceStatus.MISSING ); - } - const extraResources = resources.filter( - r => r.status === ResourceStatus.EXTRA - ); + if (missingResources.length) { + const relativePaths = missingResources.map(r => + this.mapResourceToRepoRelativePath(r) + ); + await this.runWithProgress(UpdateStatus, () => + this.repository.forget(relativePaths) + ); + } - if (extraResources.length) { - const relativePaths = extraResources.map(r => - this.mapResourceToRepoRelativePath(r) + const extraResources = resources.filter( + r => r.status === ResourceStatus.EXTRA ); - await this.runWithProgress(Operation.Add, () => - this.repository.add(relativePaths) - ); - // after 'repository.add' resource statuses change, so: - resources = this.mapResources( - resources.map(r => r.resourceUri) - ); - } - this._groups.staging.intersect(resources); - this._groups.working.except(resources); - this._onDidChangeResources.fire(); - }); + if (extraResources.length) { + const relativePaths = extraResources.map(r => + this.mapResourceToRepoRelativePath(r) + ); + await this.runWithProgress(UpdateStatus, () => + this.repository.add(relativePaths) + ); + // after 'repository.add' resource statuses change, so: + resources = this.mapResources( + resources.map(r => r.resourceUri) + ); + } + + this._groups.staging.intersect(resources); + this._groups.working.except(resources); + this._onDidChangeResources.fire(); + } + ); } // resource --> repo-relative path @@ -708,7 +670,7 @@ export class Repository implements IDisposable, InteractionAPI { scope: Exclude, newBranch: NewBranchOptions | undefined ): Promise { - return this.runWithProgress(Operation.Commit, async () => { + return this.runWithProgress(UpdateStatusAndBranch, async () => { const user = typedConfig.username; const fileList = this.scopeToFileList(scope); return this.repository.commit(message, fileList, user, newBranch); @@ -718,7 +680,7 @@ export class Repository implements IDisposable, InteractionAPI { @throttle async revert(...uris: Uri[]): Promise { const resources = this.mapResources(uris); - await this.runWithProgress(Operation.Revert, async () => { + await this.runWithProgress(UpdateStatus, async () => { const toRevert: RelativePath[] = []; for (const r of resources) { @@ -732,32 +694,35 @@ export class Repository implements IDisposable, InteractionAPI { @throttle async cleanAll(): Promise { - await this.runWithProgress(Operation.Clean, async () => + await this.runWithProgress(UpdateStatus, async () => this.repository.cleanAll() ); } @throttle async clean(paths: string[]): Promise { - await this.runWithProgress(Operation.Clean, async () => + await this.runWithProgress(UpdateStatus, async () => this.repository.clean(paths) ); } async newBranch(newBranch: NewBranchOptions): Promise { - return this.runWithProgress(Operation.Branch, () => + // Creating a new branch doesn't change anything. + return this.runWithProgress({}, () => this.repository.newBranch(newBranch) ); } async update(checkin?: FossilCheckin): Promise { - await this.runWithProgress(Operation.Update, () => - this.repository.update(checkin) + // Update command can change everything + await this.runWithProgress( + { syncText: 'Updating...', ...UpdateAll }, + () => this.repository.update(checkin) ); } async close(): Promise { - const msg = await this.runWithProgress(Operation.Close, () => + const msg = await this.runWithProgress(UpdateChanges, () => this.repository.close() ); if (msg) { @@ -777,8 +742,8 @@ export class Repository implements IDisposable, InteractionAPI { command: 'undo' | 'redo', dryRun: boolean ): Promise { - const op = dryRun ? Operation.UndoDryRun : Operation.Undo; - const undo = await this.runWithProgress(op, () => + const sideEffect = dryRun ? {} : UpdateAll; + const undo = await this.runWithProgress(sideEffect, () => this.repository.undoOrRedo(command, dryRun) ); @@ -806,24 +771,16 @@ export class Repository implements IDisposable, InteractionAPI { ); } - async changeInoutAfterDelay(delayMs = 3000): Promise { - // then confirm after delay - if (delayMs) { - await delay(delayMs); - } - this._onDidChangeInOutState.fire(); - } - @throttle async pull(name: FossilRemoteName): Promise { - return this.runWithProgress(Operation.Pull, async () => { + return this.runWithProgress(UpdateChanges, async () => { await this.repository.pull(name); }); } @throttle async push(name?: FossilRemoteName): Promise { - return this.runWithProgress(Operation.Push, async () => { + return this.runWithProgress(UpdateChanges, async () => { await this.repository.push(name); }); } @@ -833,7 +790,7 @@ export class Repository implements IDisposable, InteractionAPI { checkin: FossilCheckin, mergeAction: MergeAction ): Promise { - return this.runWithProgress(Operation.Merge, async () => { + return this.runWithProgress(UpdateStatus, async () => { return this.repository.merge(checkin, mergeAction); }); } @@ -873,7 +830,7 @@ export class Repository implements IDisposable, InteractionAPI { async cat(params: FossilUriParams): Promise { await this.whenIdleAndFocused(); - return this.runWithProgress(Operation.Show, async () => { + return this.runWithProgress({}, async () => { const relativePath = path .relative(this.repository.root, params.path) .replace(/\\/g, '/') as RelativePath; @@ -882,13 +839,13 @@ export class Repository implements IDisposable, InteractionAPI { } async patchCreate(path: string): Promise { - return this.runWithProgress(Operation.PatchCreate, async () => + return this.runWithProgress(UpdateStatus, async () => this.repository.patchCreate(path) ); } async patchApply(path: string): Promise { - return this.runWithProgress(Operation.PatchApply, async () => + return this.runWithProgress(UpdateStatus, async () => this.repository.patchApply(path) ); } @@ -898,7 +855,7 @@ export class Repository implements IDisposable, InteractionAPI { scope: Exclude, operation: 'save' | 'snapshot' ): Promise { - return this.runWithProgress(Operation.Commit, async () => + return this.runWithProgress(UpdateStatus, async () => this.repository.stash( message, operation, @@ -908,13 +865,13 @@ export class Repository implements IDisposable, InteractionAPI { } async stashList(): Promise { - return this.runWithProgress(Operation.Status, async () => + return this.runWithProgress(UpdateStatus, async () => this.repository.stashList() ); } async stashPop(): Promise { - return this.runWithProgress(Operation.Status, async () => + return this.runWithProgress(UpdateStatus, async () => this.repository.stashPop() ); } @@ -923,14 +880,15 @@ export class Repository implements IDisposable, InteractionAPI { operation: 'apply' | 'drop', stashId: StashID ): Promise { - return this.runWithProgress(Operation.Status, async () => + return this.runWithProgress(UpdateStatus, async () => this.repository.stashApplyOrDrop(operation, stashId) ); } private async runWithProgress( - operation: Operation, - runOperation: () => Promise = () => Promise.resolve(null) + sideEffects: SideEffects, + runOperation: () => Promise, + runSideEffects: (arg0: T) => boolean = () => true ): Promise { if (this.state !== RepositoryState.Idle) { throw new Error('Repository not initialized'); @@ -939,36 +897,22 @@ export class Repository implements IDisposable, InteractionAPI { return window.withProgress( { location: ProgressLocation.SourceControl }, async () => { - this._operations = new Set([ - operation, - ...this._operations.values(), - ]); - this._onRunOperation.fire(operation); + const key = Symbol(); + this._operations.set(key, sideEffects); + this._onRunOperation.fire(); try { - const result = await runOperation(); - - if (!isReadOnly(operation)) { - const err = await this.updateModelState(); - if (err) { - if ( - err.fossilErrorCode === 'NotAFossilRepository' - ) { - this.state = RepositoryState.Disposed; - } else { - throw new Error( - `Unexpected fossil result: ${String(err)}` - ); - } - } + const operationResult = await runOperation(); + if (runSideEffects(operationResult)) { + await this.updateModelState( + sideEffects, + 'Triggered by previous operation' as Reason + ); } - return result; + return operationResult; } finally { - this._operations = new Set( - this._operations.values() - ); - this._operations.delete(operation); - this._onDidRunOperation.fire(operation); + this._operations.delete(key); + this._onDidRunOperation.fire(); } } ); @@ -994,7 +938,7 @@ export class Repository implements IDisposable, InteractionAPI { /** When user selects one of the modified files using 'fossil.log' command */ async diffToParent( - filePath: string, + filePath: RelativePath, checkin: FossilCheckin ): Promise { const uri = this.toUri(filePath); @@ -1065,35 +1009,71 @@ export class Repository implements IDisposable, InteractionAPI { /** * `UpdateModelState` is called after every non read only operation run + * + * what we do here: + * - execute `fossil status --differ --merge` to get status + * - execute `fossil branch current`to get current branch + * - parse status output to update SCM tree + * - get `changes` */ @throttle public async updateModelState( + sideEffects: SideEffects, reason: Reason = 'model state is updating' as Reason + ): Promise { + const sidePromises: Promise[] = []; + if (sideEffects.status) { + sidePromises.push( + this.updateStatus(reason).then(err => { + if (err) { + if (err.fossilErrorCode === 'NotAFossilRepository') { + this.state = RepositoryState.Disposed; + } else { + throw new Error( + `Unexpected fossil result: ${String(err)}` + ); + } + } + }) + ); + } + if (sideEffects.changes) { + sidePromises.push(this.updateChanges(reason)); + } + if (sideEffects.branch) { + sidePromises.push(this.updateBranch()); + } + await Promise.all(sidePromises); + } + + public async updateStatus( + reason?: Reason ): Promise { const result = await this.repository.getStatus(reason); if (result.exitCode) { return result; } - const currentBranchPromise = this.repository.getCurrentBranch(); const fossilStatus = (this._fossilStatus = this.repository.parseStatusString(result.stdout as StatusString)); - this._currentBranch = await currentBranchPromise; - - const groupInput = { + groupStatuses({ repositoryRoot: this.repository.root, fileStatuses: fossilStatus.statuses, statusGroups: this._groups, - }; - - groupStatuses(groupInput); + }); this._sourceControl.count = this.count; - this._onDidChangeStatus.fire(); - // this._onDidChangeRepository.fire() return; } + private async updateBranch() { + const currentBranch = await this.repository.getCurrentBranch(); + if (this._currentBranch !== currentBranch) { + this._currentBranch = currentBranch; + } + this.updateInputBoxPlaceholder(); + } + private get count(): number { return ( this.stagingGroup.resourceStates.length + @@ -1103,6 +1083,54 @@ export class Repository implements IDisposable, InteractionAPI { ); } + public updateAutoSyncInterval(interval: AutoSyncIntervalMs): void { + clearTimeout(this.autoSyncTimer); + // ensure interval is ether 0 or minimum 15 seconds + interval = + interval && (Math.max(interval, 15000) as AutoSyncIntervalMs); + const nextSyncTime = interval + ? new Date(Date.now() + interval) + : undefined; + this.statusBar.onSyncTimeUpdated(nextSyncTime); + if (interval) { + this.autoSyncTimer = setTimeout( + () => this.periodicSync(), + interval + ); + } + } + + /** + * Reads `changes` from `fossil update --dry-run --latest` + * and updates the status bar + */ + private async updateChanges(reason: Reason): Promise { + const updateResult = await this.repository.update( + undefined, + true, + reason + ); + this.statusBar.onChangesReady(updateResult); + } + + async periodicSync(): Promise { + await this.repository.exec(['sync'], 'periodic sync' as Reason, { + logErrors: false, + }); + await this.updateChanges('sync happened' as Reason); + this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs); + } + + async sync(): Promise { + const res = await this.runWithProgress( + { changes: true, syncText: 'Syncing' }, + async () => this.repository.exec(['sync']), + res => !res.exitCode + ); + this.statusBar.onSyncReady(res); + this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs); + } + dispose(): void { dispose(this.disposables); } diff --git a/src/statusBar.ts b/src/statusBar.ts new file mode 100644 index 0000000..7380916 --- /dev/null +++ b/src/statusBar.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Arseniy Terekhin. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Command, SourceControl } from 'vscode'; +import { Repository } from './repository'; +import { ageFromNow, Old } from './humanise'; +import { localize } from './main'; +import { ExecResult } from './fossilExecutable'; + +/** + * A bar with 'sync' icon; + * - should run `fossil up` command for specific repository when clicked [ok] + * - the tooltip should show 'changes' that are shown when running `fossil up --dry-run` + * - should show number of remote changes (if != 0) + * - should be animated when sync/update is running + * - sync should be rescheduled after `sync` or `update` commands + * - handle case when there's no sync URL + */ +class SyncBar { + private icon: 'sync' | 'warning' = 'sync'; // 'sync-ignored' is nice, but not intuitive + private text = ''; + private syncMessage: `${string}\n` | '' = ''; + /** + * match for /changes:\s*(string)/ + * like `17 files modified.` or ` None. Already up-to-date` + */ + private changes: string = ''; // + private nextSyncTime: Date | undefined; // undefined = no auto syncing + + constructor(private repository: Repository) {} + + public onChangesReady(updateResult: ExecResult) { + if (!updateResult.exitCode) { + const match = updateResult.stdout.match(/^changes:\s*((\d*).*)/m); + this.changes = match?.[1] ?? 'unknown changes'; + this.text = match?.[2] ?? ''; // digits of nothing + } else { + this.changes = this.text = ''; + } + } + + public onSyncReady(result: ExecResult) { + this.icon = 'sync'; + if (!result.exitCode) { + this.syncMessage = ''; + } else { + if (/^Usage: /.test(result.stderr)) { + // likely only local repo + this.syncMessage = 'repository with no remote\n'; + } else { + this.icon = 'warning'; + this.syncMessage = `Sync error: ${result.stderr}\n`; + } + } + } + + public onSyncTimeUpdated(date: Date | undefined) { + this.nextSyncTime = date; + } + + public get command(): Command { + const timeMessage = this.nextSyncTime + ? `Next sync ${this.nextSyncTime.toTimeString().split(' ')[0]}` + : `Auto sync disabled`; + return { + command: 'fossil.update', + title: `$(${this.icon}) ${this.text}`.trim(), + tooltip: `${timeMessage}\n${this.syncMessage}${this.changes}\nUpdate`, + arguments: [this.repository satisfies Repository], + }; + } +} + +/** + * Create `vscode.Command` that executes 'fossil.branchChange' + * decorated with icon, branch name, and repository status + * with branch details in the tooltip + */ +function branchCommand(repository: Repository): Command { + const { currentBranch, fossilStatus } = repository; + const icon = fossilStatus!.isMerge ? '$(git-merge)' : '$(git-branch)'; + const title = + icon + + ' ' + + (currentBranch || 'unknown') + + (repository.conflictGroup.resourceStates.length + ? '!' + : repository.workingGroup.resourceStates.length + ? '+' + : ''); + let checkoutAge = ''; + const d = new Date(fossilStatus!.checkout.date.replace(' UTC', '.000Z')); + checkoutAge = ageFromNow(d, Old.EMPTY_STRING); + + return { + command: 'fossil.branchChange', + tooltip: localize( + 'branch change {0} {1}{2} {3}', + '{0}\n{1}{2}\nTags:\n • {3}\nChange Branch...', + fossilStatus!.checkout.checkin, + fossilStatus!.checkout.date, + checkoutAge && ` (${checkoutAge})`, + fossilStatus!.tags.join('\n • ') + ), + title, + arguments: [repository satisfies Repository], + }; +} + +export class StatusBarCommands { + private readonly syncBar: SyncBar; + + constructor( + private readonly repository: Repository, + private readonly sourceControl: SourceControl + ) { + this.syncBar = new SyncBar(repository); + this.update(); + } + + public onChangesReady(updateResult: ExecResult) { + this.syncBar.onChangesReady(updateResult); + this.update(); + } + + public onSyncTimeUpdated(date: Date | undefined) { + this.syncBar.onSyncTimeUpdated(date); + this.update(); + } + + public onSyncReady(syncResult: ExecResult) { + this.syncBar.onSyncReady(syncResult); + this.update(); + } + + /** + * Should be called whenever commands text/actions/tooltips + * are updated + */ + public update(): void { + let commands: Command[]; + if (this.repository.fossilStatus) { + const update = branchCommand(this.repository); + const sideEffects = this.repository.operations; + const messages = []; + for (const [, se] of sideEffects) { + if (se.syncText) { + messages.push(se.syncText); + } + } + messages.sort(); + const sync = messages.length + ? { + title: '$(sync~spin)', + command: '', + tooltip: messages.join('\n'), + } + : this.syncBar.command; + + commands = [update, sync]; + } else { + // this class was just initialized, repository status is unknown + commands = [ + { + command: '', + tooltip: localize( + 'loading {0}', + 'Loading {0}', + this.repository.root + ), + title: '$(sync~spin)', + }, + ]; + } + this.sourceControl.statusBarCommands = commands; + } +} diff --git a/src/statusbar.ts b/src/statusbar.ts deleted file mode 100644 index 1ddb39b..0000000 --- a/src/statusbar.ts +++ /dev/null @@ -1,262 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Ben Crowl. All rights reserved. - * Original Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, Command, EventEmitter, Event } from 'vscode'; -import { anyEvent, dispose } from './util'; -import { AutoInOutStatuses, AutoInOutState } from './autoinout'; -import { Repository, Operation } from './repository'; -import { ageFromNow, Old } from './humanise'; - -import { localize } from './main'; -import { CommandId } from './commands'; - -const enum SyncStatus { - None = 0, - Pushing = 1, - Pulling = 2, -} - -class ScopeStatusBar { - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - private disposables: Disposable[] = []; - - constructor(private repository: Repository) { - repository.onDidChangeStatus( - this._onDidChange.fire, - this._onDidChange, - this.disposables - ); - } - - get command(): Command | undefined { - const { currentBranch, fossilStatus } = this.repository; - if (!currentBranch) { - return; - } - const icon = fossilStatus?.isMerge ? '$(git-merge)' : '$(git-branch)'; - const title = - icon + - ' ' + - currentBranch + - (this.repository.workingGroup.resourceStates.length ? '+' : ''); - let age = ''; - if (fossilStatus) { - const d = new Date( - fossilStatus.checkout.date.replace(' UTC', '.000Z') - ); - age = ageFromNow(d, Old.EMPTY_STRING); - } - - return { - command: 'fossil.branchChange', - tooltip: localize( - 'branch change {0} {1}{2} {3}', - '\n{0}\n{1}{2}\nTags:\n \u2022 {3}\nChange Branch...', - fossilStatus?.checkout.checkin, - fossilStatus?.checkout.date, - age && ` (${age})`, - fossilStatus?.tags.join('\n \u2022 ') - ), - title, - arguments: [this.repository], - }; - } - - dispose(): void { - this.disposables.forEach(d => d.dispose()); - } -} - -interface SyncStatusBarState { - autoInOut: AutoInOutState; - syncStatus: SyncStatus; - nextCheckTime: Date; -} - -class SyncStatusBar { - private static StartState: SyncStatusBarState = { - autoInOut: { - status: AutoInOutStatuses.Disabled, - error: '', - }, - nextCheckTime: new Date(), - syncStatus: SyncStatus.None, - }; - - private _onDidChange = new EventEmitter(); - get onDidChange(): Event { - return this._onDidChange.event; - } - private disposables: Disposable[] = []; - - private _state: SyncStatusBarState = SyncStatusBar.StartState; - private get state() { - return this._state; - } - private set state(state: SyncStatusBarState) { - this._state = state; - this._onDidChange.fire(); - } - - constructor(private repository: Repository) { - repository.onDidChange(this.onModelChange, this, this.disposables); - repository.onDidChangeOperations( - this.onOperationsChange, - this, - this.disposables - ); - this._onDidChange.fire(); - } - - private getSyncStatus(): SyncStatus { - if (this.repository.operations.has(Operation.Push)) { - return SyncStatus.Pushing; - } - - if (this.repository.operations.has(Operation.Pull)) { - return SyncStatus.Pulling; - } - - return SyncStatus.None; - } - - private onOperationsChange(): void { - this.state = { - ...this.state, - syncStatus: this.getSyncStatus(), - autoInOut: this.repository.autoInOutState, - }; - } - - private onModelChange(): void { - this.state = { - ...this.state, - autoInOut: this.repository.autoInOutState, - }; - } - - private describeAutoInOutStatus(): { - icon: string; - message?: string; - status: AutoInOutStatuses; - } { - const { autoInOut } = this.state; - switch (autoInOut.status) { - case AutoInOutStatuses.Enabled: - if (autoInOut.nextCheckTime) { - const time = autoInOut.nextCheckTime.toLocaleTimeString(); - const message = localize( - 'synced next check', - 'Synced (next check {0})', - time - ); - - return { - icon: '$(sync)', - message, - status: AutoInOutStatuses.Enabled, - }; - } else { - return { - icon: '', - message: 'Enabled but no next sync time', - status: AutoInOutStatuses.Enabled, - }; - } - - case AutoInOutStatuses.Error: - return { - icon: '$(stop)', - message: `${localize('remote error', 'Remote error')}: ${ - autoInOut.error - }`, - status: AutoInOutStatuses.Error, - }; - - case AutoInOutStatuses.Disabled: - default: { - const message = localize('sync', 'Sync'); - return { - icon: '$(sync-ignored)', - message, - status: AutoInOutStatuses.Disabled, - }; - } - } - } - - get command(): Command | undefined { - const autoInOut = this.describeAutoInOutStatus(); - let icon = autoInOut.icon; - let text = ''; - let command: CommandId | '' = 'fossil.update'; - let tooltip = autoInOut.message; - - const { syncStatus } = this.state; - if (syncStatus) { - icon = '$(sync~spin)'; - text = ''; - command = ''; - tooltip = localize('syncing', 'Syncing changes...'); - } - - return { - command, - title: `${icon} ${text}`.trim(), - tooltip, - arguments: [this.repository], - }; - } - - dispose(): void { - this.disposables.forEach(d => d.dispose()); - } -} - -export class StatusBarCommands { - private readonly syncStatusBar: SyncStatusBar; - private readonly scopeStatusBar: ScopeStatusBar; - private readonly disposables: Disposable[] = []; - - constructor(repository: Repository) { - this.syncStatusBar = new SyncStatusBar(repository); - this.scopeStatusBar = new ScopeStatusBar(repository); - } - - get onDidChange(): Event { - return anyEvent( - this.syncStatusBar.onDidChange, - this.scopeStatusBar.onDidChange - ); - } - - get commands(): Command[] { - const result: Command[] = []; - - const update = this.scopeStatusBar.command; - - if (update) { - result.push(update); - } - - const sync = this.syncStatusBar.command; - - if (sync) { - result.push(sync); - } - - return result; - } - - dispose(): void { - this.syncStatusBar.dispose(); - this.scopeStatusBar.dispose(); - dispose(this.disposables); - } -} diff --git a/src/test/suite/branchSuite.ts b/src/test/suite/branchSuite.ts index a1889f5..52d2d0a 100644 --- a/src/test/suite/branchSuite.ts +++ b/src/test/suite/branchSuite.ts @@ -200,6 +200,11 @@ export function BranchSuite(this: Suite): void { sinon.assert.calledOnce(cib); sinon.assert.calledOnce(swm); sinon.assert.calledOnce(newBranchStub); - sinon.assert.calledOnceWithExactly(updateStub, ['update', 'trunk']); + sinon.assert.calledOnceWithExactly( + updateStub, + ['update', 'trunk'], + undefined, + { logErrors: true } + ); }); } diff --git a/src/test/suite/commandSuites.ts b/src/test/suite/commandSuites.ts index a9f33e6..7005adf 100644 --- a/src/test/suite/commandSuites.ts +++ b/src/test/suite/commandSuites.ts @@ -5,11 +5,16 @@ import { add, assertGroups, cleanupFossil, + fakeExecutionResult, + fakeFossilBranch, + fakeFossilChanges, fakeFossilStatus, fakeRawExecutionResult, + fakeStatusResult, getExecStub, getRawExecStub, getRepository, + statusBarCommands, } from './common'; import * as assert from 'assert/strict'; import * as fs from 'fs/promises'; @@ -46,7 +51,7 @@ export function StatusSuite(this: Suite): void { ); await fs.unlink(path.fsPath); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: [[path.fsPath, ResourceStatus.MISSING]], }); @@ -58,14 +63,14 @@ export function StatusSuite(this: Suite): void { const oldFilename = 'sriciscp-new.txt'; const newFilename = 'sriciscp-renamed.txt'; const oldUri = await add(oldFilename, 'test\n', `add ${oldFilename}`); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, {}); const openedRepository: OpenedRepository = (repository as any) .repository; await openedRepository.exec(['mv', oldFilename, newFilename, '--hard']); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const barPath = Uri.joinPath(oldUri, '..', newFilename).fsPath; assertGroups(repository, { working: [[barPath, ResourceStatus.RENAMED]], @@ -96,7 +101,7 @@ export function StatusSuite(this: Suite): void { await openedRepository.exec(['update', 'trunk']); await openedRepository.exec(['merge', 'test_brunch']); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: [ [barPath, ResourceStatus.ADDED], @@ -174,7 +179,7 @@ export function StatusSuite(this: Suite): void { await fs.unlink(not_file_path); await fs.mkdir(not_file_path); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: [ [executable_path, ResourceStatus.MODIFIED], @@ -195,8 +200,8 @@ export function StatusSuite(this: Suite): void { ) => { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); - await fakeFossilStatus(execStub, status); - await repository.updateModelState(); + fakeFossilStatus(execStub, status); + await repository.updateStatus('Test' as Reason); const root = vscode.workspace.workspaceFolders![0].uri; const uriBefore = Uri.joinPath(root, before); const uriAfter = Uri.joinPath(root, after); @@ -238,6 +243,96 @@ export function StatusSuite(this: Suite): void { ResourceStatus.MODIFIED ); }); + + test('"Refresh" command refreshes everything', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const status = fakeFossilStatus(execStub, 'EXTRA refresh.txt\n'); + const branch = fakeFossilBranch(execStub, 'refresh'); + const changes = fakeFossilChanges(execStub, '12 files modified.'); + await commands.executeCommand('fossil.refresh'); + sinon.assert.calledThrice(execStub); + sinon.assert.calledOnce(status); + sinon.assert.calledOnce(branch); + sinon.assert.calledOnce(changes); + + // reset everything, not leaving 'refresh' as current branch + branch.resolves(fakeExecutionResult({ stdout: 'trunk' })); + changes.resolves( + fakeExecutionResult({ stdout: 'changes: None. Already up-to-date' }) + ); + status.resolves(fakeStatusResult('')); + await commands.executeCommand('fossil.refresh'); + assertGroups(getRepository(), {}); + }); + + test('Branch change is reflected in status bar', async () => { + // 1. Check current branch name + const branchCommandBefore = statusBarCommands()[0]; + assert.equal(branchCommandBefore.title, '$(git-branch) trunk'); + + // 2. Create branch + const branchName = 'statusbar1'; + const cib = this.ctx.sandbox.stub(window, 'createInputBox'); + cib.onFirstCall().callsFake(() => { + const inputBox: vscode.InputBox = cib.wrappedMethod(); + const stub = sinon.stub(inputBox); + stub.show.callsFake(() => { + stub.value = branchName; + const onDidAccept = stub.onDidAccept.getCall(0).args[0]; + onDidAccept(); + }); + return stub; + }); + + const execStub = getExecStub(this.ctx.sandbox); + fakeFossilStatus(execStub, '\n'); // ensure branch doesn't get '+' + const branchCreation = execStub.withArgs([ + 'branch', + 'new', + branchName, + 'current', + ]); + await commands.executeCommand('fossil.branch'); + sinon.assert.calledOnce(branchCreation); + + // 3. Change the branch + const branchSwitch = execStub.withArgs(['update', branchName]); + const sqp = this.ctx.sandbox.stub(window, 'showQuickPick'); + sqp.onFirstCall().callsFake(items => { + assert.ok(items instanceof Array); + const item = items.find( + item => item.label == `$(git-branch) ${branchName}` + ); + assert.ok(item); + assert.equal(item.description, ''); + assert.equal(item.detail, undefined); + return Promise.resolve(item); + }); + await commands.executeCommand('fossil.branchChange'); + sinon.assert.calledOnce(sqp); + sinon.assert.calledOnce(branchSwitch); + + // 4. Check branch name is changed + const branchCommandAfter = statusBarCommands()[0]; + assert.equal(branchCommandAfter.title, `$(git-branch) ${branchName}`); + + // 5. Change branch back to 'trunk' + sqp.onSecondCall().callsFake(items => { + assert.ok(items instanceof Array); + const item = items.find( + item => item.label == '$(git-branch) trunk' + ); + assert.ok(item); + assert.equal(item.label, `$(git-branch) trunk`); + assert.equal(item.description, ''); + assert.equal(item.detail, undefined); + return Promise.resolve(item); + }); + await commands.executeCommand('fossil.branchChange'); + sinon.assert.calledTwice(sqp); + const branchCommandLast = statusBarCommands()[0]; + assert.equal(branchCommandLast.title, `$(git-branch) trunk`); + }).timeout(20000); } export function TagSuite(this: Suite): void { @@ -316,8 +411,8 @@ export function CleanSuite(this: Suite): void { const cleanCallStub = execStub .withArgs(sinon.match.array.startsWith(['clean'])) .resolves(); - await fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt'); - await repository.updateModelState(); + fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt'); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [ [Uri.joinPath(rootUri, 'a.txt').fsPath, ResourceStatus.EXTRA], @@ -354,11 +449,8 @@ export function CleanSuite(this: Suite): void { const cleanCallStub = execStub .withArgs(sinon.match.array.startsWith(['clean'])) .resolves(); - await fakeFossilStatus( - execStub, - 'EXTRA a.txt\nEXTRA b.txt\nEXTRA c.txt' - ); - await repository.updateModelState(); + fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt\nEXTRA c.txt'); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [ [Uri.joinPath(rootUri, 'a.txt').fsPath, ResourceStatus.EXTRA], @@ -429,7 +521,7 @@ export function DiffSuite(this: Suite): void { const uri = Uri.joinPath(rootUri, 'a_path.txt'); const execStub = getExecStub(this.ctx.sandbox); const statusCall = fakeFossilStatus(execStub, 'ADDED a_path.txt'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusCall); const testTd = { isUntitled: false } as vscode.TextDocument; diff --git a/src/test/suite/commitSuite.ts b/src/test/suite/commitSuite.ts index a094e1b..6d2f0de 100644 --- a/src/test/suite/commitSuite.ts +++ b/src/test/suite/commitSuite.ts @@ -33,8 +33,10 @@ export const commitStagedTest = async ( const commitStub = execStub .withArgs(sinon.match.array.startsWith(['commit'])) .resolves(fakeExecutionResult()); - await repository.updateModelState(); + await repository.updateStatus(); + sinon.assert.calledOnce(statusStub); await commands.executeCommand('fossil.stageAll'); + sinon.assert.calledOnce(statusStub); const sib = sandbox.stub(window, 'showInputBox').resolves('test message'); await commands.executeCommand(command); sinon.assert.calledTwice(statusStub); @@ -60,7 +62,7 @@ const singleFileCommitSetup = async ( const repository = getRepository(); const execStub = getExecStub(sandbox); const statusStub = fakeFossilStatus(execStub, 'ADDED minimal.txt\n'); - await repository.updateModelState('test' as Reason); + await repository.updateStatus('test' as Reason); sinon.assert.calledOnce(statusStub); assertGroups(repository, { working: [ @@ -96,7 +98,7 @@ export function CommitSuite(this: Suite): void { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); const statusStub = fakeFossilStatus(execStub, 'ADDED fake.txt\n'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); assertGroups(repository, { working: [ @@ -141,7 +143,7 @@ export function CommitSuite(this: Suite): void { const commitStub = execStub .withArgs(sinon.match.array.startsWith(['commit'])) .resolves(fakeExecutionResult()); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assert.ok(repository.workingGroup.resourceStates[1]); await commands.executeCommand( 'fossil.stage', @@ -171,7 +173,7 @@ export function CommitSuite(this: Suite): void { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); const statusStub = fakeFossilStatus(execStub, '\n'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); assertGroups(repository, {}); @@ -193,7 +195,7 @@ export function CommitSuite(this: Suite): void { await fs.writeFile(uri.fsPath, 'content'); const execStub = getExecStub(this.ctx.sandbox); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [[uri.fsPath, ResourceStatus.EXTRA]], }); @@ -232,7 +234,7 @@ export function CommitSuite(this: Suite): void { const repository = getRepository(); repository.sourceControl.inputBox.value = 'creating new branch'; - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource = repository.untrackedGroup.getResource(branchPath); assert.ok(resource); await commands.executeCommand('fossil.add', resource); @@ -270,7 +272,7 @@ export function CommitSuite(this: Suite): void { await fs.writeFile(uri1.fsPath, 'warning test'); await fs.writeFile(uri2.fsPath, 'warning test'); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource1 = repository.workingGroup.getResource(uri1); assert.ok(resource1); await commands.executeCommand('fossil.stage', resource1); @@ -328,7 +330,7 @@ export function CommitSuite(this: Suite): void { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); const statusStub = fakeFossilStatus(execStub, 'ADDED a\nCONFLICT b'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); repository.sourceControl.inputBox.value = 'must not be committed'; const swm: sinon.SinonStub = this.ctx.sandbox @@ -352,7 +354,7 @@ export function CommitSuite(this: Suite): void { execStub, 'ADDED a\nMISSING b\nMISSING c' ); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); const swm: sinon.SinonStub = this.ctx.sandbox .stub(window, 'showWarningMessage') @@ -390,7 +392,7 @@ export function CommitSuite(this: Suite): void { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); const statusStub = fakeFossilStatus(execStub, 'ADDED a\nMISSING b'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); repository.sourceControl.inputBox.value = 'must not commit'; const swm: sinon.SinonStub = this.ctx.sandbox diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index 72f3afe..9937ef6 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -97,19 +97,22 @@ export async function fossilInit(sandbox: sinon.SinonSandbox): Promise { sandbox.restore(); } -export function getRepository(): Repository { +export function getModel(): Model { const extension = vscode.extensions.getExtension('koog1000.fossil'); assert.ok(extension); const model = extension.exports as Model; + assert.ok(model, "extension initialization didn't succeed"); + return model; +} + +export function getRepository(): Repository { + const model = getModel(); assert.equal(model.repositories.length, 1); return model.repositories[0]; } export function getExecutable(): FossilExecutable { - const extension = vscode.extensions.getExtension('koog1000.fossil'); - assert.ok(extension); - const model = extension.exports as Model; - assert.ok(model, "extension initialization didn't succeed"); + const model = getModel(); const executable = model['executable']; assert.ok(executable); return executable; @@ -173,18 +176,37 @@ export function fakeRawExecutionResult({ }; } -export function fakeFossilStatus(execStub: ExecStub, status: string): ExecStub { +export function fakeStatusResult(status: string): ExecResult { + const args: FossilArgs = ['status', '--differ', '--merge']; const header = 'checkout: 0000000000000000000000000000000000000000 2023-05-26 12:43:56 UTC\n' + 'parent: 0000000000000000000000000000000000000001 2023-05-26 12:43:56 UTC\n' + 'tags: trunk, this is a test, custom tag\n'; - const args: FossilArgs = ['status', '--differ', '--merge']; - execStub + return fakeExecutionResult({ stdout: header + status, args }); +} + +export function fakeFossilStatus(execStub: ExecStub, status: string): ExecStub { + return execStub + .withArgs(['status', '--differ', '--merge']) + .resolves(fakeStatusResult(status)); +} + +export function fakeFossilBranch( + execStub: ExecStub, + branch: 'refresh' | 'trunk' +): ExecStub { + return execStub .withArgs(['branch', 'current']) - .resolves(fakeExecutionResult({ stdout: 'trunk' })); + .resolves(fakeExecutionResult({ stdout: branch })); +} + +export function fakeFossilChanges( + execStub: ExecStub, + changes: `${number} files modified.` | 'None. Already up-to-date' +): ExecStub { return execStub - .withArgs(args) - .resolves(fakeExecutionResult({ stdout: header + status, args })); + .withArgs(['update', '--dry-run', '--latest']) + .resolves(fakeExecutionResult({ stdout: `changes: ${changes}\n` })); } async function setupFossilOpen(sandbox: sinon.SinonSandbox) { @@ -268,6 +290,7 @@ export async function fossilOpenForce( if (/check-ins:\s+1\s*$/.test(res.stdout)) { break; } + /* c8 ignore next 2 */ await delay((i + 1) * 111); } assert.match(res!.stdout, /check-ins:\s+1\s*$/); @@ -317,7 +340,7 @@ export async function cleanupFossil(repository: Repository): Promise { ); assert.equal(cleanRes1.exitCode, 0); - const updateRes = await repository.updateModelState( + const updateRes = await repository.updateStatus( 'Test: cleanupFossil' as Reason ); assert.equal(updateRes, undefined); @@ -368,3 +391,10 @@ export function assertGroups( message ); } + +export function statusBarCommands() { + const repository = getRepository(); + const commands = repository.sourceControl.statusBarCommands; + assert.ok(commands); + return commands; +} diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 9a9782d..e85b46f 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -19,6 +19,7 @@ import { RenameSuite } from './renameSuite'; import { BranchSuite } from './branchSuite'; import { RevertSuite } from './revertSuite'; import { GitExportSuite } from './gitExportSuite'; +import { StatusBarSuite } from './statusBarSuite'; suite('Fossil.OpenedRepo', function (this: Suite) { this.ctx.sandbox = sinon.createSandbox(); @@ -31,6 +32,7 @@ suite('Fossil.OpenedRepo', function (this: Suite) { suite('Utilities', utilitiesSuite); suite('Update', UpdateSuite); + suite('Status Bar', StatusBarSuite); suite('Resource Actions', resourceActionsSuite); suite('Timeline', timelineSuite); suite('Revert', RevertSuite); diff --git a/src/test/suite/mergeSuite.ts b/src/test/suite/mergeSuite.ts index acebd87..d85acfd 100644 --- a/src/test/suite/mergeSuite.ts +++ b/src/test/suite/mergeSuite.ts @@ -101,8 +101,7 @@ export function MergeSuite(this: Suite): void { ]); await openedRepository.exec(['update', 'trunk']); - await commands.executeCommand('fossil.refresh'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, {}); const sqp: sinon.SinonStub = this.ctx.sandbox.stub( @@ -119,7 +118,7 @@ export function MergeSuite(this: Suite): void { sinon.assert.calledOnce(sqp); sinon.assert.calledOnce(sib); - await repository.updateModelState('test' as Reason); + await repository.updateStatus('test' as Reason); assertGroups(repository, {}); }).timeout(5000); @@ -129,7 +128,7 @@ export function MergeSuite(this: Suite): void { .withArgs(['branch', 'ls', '-t']) .resolves(fakeExecutionResult({ stdout: ' * a\n b\n c\n' })); fakeFossilStatus(execStub, 'INTEGRATE 0123456789'); - await getRepository().updateModelState(); + await getRepository().updateStatus('Test' as Reason); const sqp = this.ctx.sandbox.stub(window, 'showQuickPick'); const swm: sinon.SinonStub = this.ctx.sandbox .stub(window, 'showWarningMessage') @@ -154,7 +153,7 @@ export function MergeSuite(this: Suite): void { .withArgs(['branch', 'ls', '-t']) .resolves(fakeExecutionResult({ stdout: ' * a\n b\n c\n' })); fakeFossilStatus(execStub, 'INTEGRATE 0123456789'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const mergeStub = execStub .withArgs(['merge', 'c', '--integrate']) .resolves(fakeExecutionResult()); @@ -208,7 +207,7 @@ export function MergeSuite(this: Suite): void { .resolves(fakeExecutionResult({ stdout: ' * a\n b\n c\n' })); fakeFossilStatus(execStub, ''); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); let hash = ''; const mergeCallStub = execStub .withArgs(sinon.match.array.startsWith(['merge'])) diff --git a/src/test/suite/renameSuite.ts b/src/test/suite/renameSuite.ts index 94c8909..bc90948 100644 --- a/src/test/suite/renameSuite.ts +++ b/src/test/suite/renameSuite.ts @@ -47,7 +47,7 @@ export function RenameSuite(this: Suite): void { const repository = getRepository(); await answeredYes; await eventToPromise(repository.onDidRunOperation); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: [[newFilePath.fsPath, ResourceStatus.RENAMED]], @@ -77,10 +77,7 @@ export function RenameSuite(this: Suite): void { ) as sinon.SinonStub ).resolves("Don't show again"); - const status = await fakeFossilStatus( - execStub, - `EDITED ${oldFilename}\n` - ); + const status = fakeFossilStatus(execStub, `EDITED ${oldFilename}\n`); const success = await workspace.applyEdit(edit); assert.ok(success); sinon.assert.calledOnceWithExactly( @@ -94,6 +91,7 @@ export function RenameSuite(this: Suite): void { if (sim.callCount != 0) { break; } + /* c8 ignore next 2 */ await delay(5); } sinon.assert.calledOnceWithExactly( @@ -168,7 +166,7 @@ export function RenameSuite(this: Suite): void { await answeredYes; await eventToPromise(repository.onDidRunOperation); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: newUris.map((url: Uri) => [ @@ -192,7 +190,7 @@ export function RenameSuite(this: Suite): void { 'ADDED' ); await fs.rename(oldUri.fsPath, newUri.fsPath); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: [[oldUri.fsPath, ResourceStatus.MISSING]], untracked: [[newUri.fsPath, ResourceStatus.EXTRA]], diff --git a/src/test/suite/resourceActionsSuite.ts b/src/test/suite/resourceActionsSuite.ts index 0ff4e49..6aa9c51 100644 --- a/src/test/suite/resourceActionsSuite.ts +++ b/src/test/suite/resourceActionsSuite.ts @@ -66,12 +66,12 @@ export function resourceActionsSuite(this: Suite): void { await fs.writeFile(uri.fsPath, 'fossil_add'); const repository = getRepository(); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); const resource = repository.untrackedGroup.getResource(uri); assert.ok(resource); await commands.executeCommand('fossil.add', resource); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); assert.ok(!repository.untrackedGroup.includesUri(uri)); assert.ok(repository.stagingGroup.includesUri(uri)); }).timeout(5000); @@ -81,7 +81,7 @@ export function resourceActionsSuite(this: Suite): void { let statusStub = fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt'); const repository = getRepository(); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); assertGroups(repository, { untracked: [ @@ -111,7 +111,7 @@ export function resourceActionsSuite(this: Suite): void { await cleanupFossil(repository); const execStub = getExecStub(this.ctx.sandbox); const statusStub = fakeFossilStatus(execStub, 'ADDED a\nADDED b'); - await repository.updateModelState('test' as Reason); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); assertGroups(repository, { working: [ @@ -135,11 +135,8 @@ export function resourceActionsSuite(this: Suite): void { const forgetCallStub = execStub .withArgs(sinon.match.array.startsWith(['forget'])) .resolves(); - await fakeFossilStatus( - execStub, - 'ADDED a.txt\nEDITED b.txt\nEXTRA c.txt' - ); - await repository.updateModelState(); + fakeFossilStatus(execStub, 'ADDED a.txt\nEDITED b.txt\nEXTRA c.txt'); + await repository.updateStatus('Test' as Reason); await commands.executeCommand( 'fossil.forget', ...repository.workingGroup.resourceStates @@ -180,7 +177,7 @@ export function resourceActionsSuite(this: Suite): void { await fs.writeFile(uriToIgnore.fsPath, `autogenerated\n`); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource = repository.untrackedGroup.getResource(uriToIgnore); assert.ok(resource); assert.ok(!existsSync(urlIgnoredGlob.fsPath)); @@ -200,7 +197,7 @@ export function resourceActionsSuite(this: Suite): void { // now append to ignore list const uriToIgnore2 = Uri.joinPath(rootUri, 'autogenerated2'); await fs.writeFile(uriToIgnore2.fsPath, `autogenerated2\n`); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource2 = repository.untrackedGroup.getResource(uriToIgnore2); assert.ok(resource2); await documentWasShown( @@ -230,7 +227,7 @@ export function resourceActionsSuite(this: Suite): void { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); fakeFossilStatus(execStub, `ADDED a\nADDED b\n`); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { working: [ [Uri.joinPath(rootUri, 'a').fsPath, ResourceStatus.ADDED], @@ -259,7 +256,7 @@ export function resourceActionsSuite(this: Suite): void { await fs.writeFile(uriToOpen.fsPath, `text inside\n`); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource = repository.untrackedGroup.getResource(uriToOpen); assert.ok(resource); @@ -289,7 +286,7 @@ export function resourceActionsSuite(this: Suite): void { execStub, `${status} open_resource.txt` ); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); const resource = repository.workingGroup.getResource(uri); assert.ok(resource); @@ -377,7 +374,7 @@ export function resourceActionsSuite(this: Suite): void { await fs.writeFile(uri.fsPath, 'opened'); const repository = getRepository(); // make file available in 'untracked' group - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const document = await workspace.openTextDocument(uri); await window.showTextDocument(document, { preview: false }); diff --git a/src/test/suite/revertSuite.ts b/src/test/suite/revertSuite.ts index 45422ef..dfadf4a 100644 --- a/src/test/suite/revertSuite.ts +++ b/src/test/suite/revertSuite.ts @@ -6,6 +6,7 @@ import * as fs from 'fs/promises'; import type { Suite } from 'mocha'; import type { FossilResourceGroup } from '../../resourceGroups'; import type { FossilResource } from '../../repository'; +import { Reason } from '../../fossilExecutable'; export function RevertSuite(this: Suite): void { test('Single source', async () => { @@ -17,7 +18,7 @@ export function RevertSuite(this: Suite): void { await fs.writeFile(url.fsPath, 'something new'); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource = repository.workingGroup.getResource(url); assert.ok(resource); @@ -56,7 +57,7 @@ export function RevertSuite(this: Suite): void { execStub, fake_status.join('\n') ); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusCall); const resources = fileUris.map(uri => { const resource = repository.workingGroup.getResource(uri); @@ -145,7 +146,7 @@ export function RevertSuite(this: Suite): void { const revertStub = execStub .withArgs(sinon.match.array.startsWith(['revert'])) .resolves(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); sinon.assert.calledOnce(statusStub); await commands.executeCommand('fossil.revertAll', ...groups); sinon.assert.calledOnceWithExactly( diff --git a/src/test/suite/stateSuite.ts b/src/test/suite/stateSuite.ts index b7aeae5..65b2911 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -171,9 +171,9 @@ export function UpdateSuite(this: Suite): void { const selectTrunk = async () => { const execStub = getExecStub(this.ctx.sandbox); - await fakeFossilStatus(execStub, 'ADDED fake.txt\nCHERRYPICK aaa'); + fakeFossilStatus(execStub, 'ADDED fake.txt\nCHERRYPICK aaa'); const repository = getRepository(); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assert.ok(repository.fossilStatus?.isMerge); const updateCall = execStub.withArgs(['update', 'trunk']).resolves(); @@ -301,7 +301,7 @@ export function StashSuite(this: Suite): void { 'stashSave commit message', 'stash.txt', ]); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); const resource = repository.untrackedGroup.getResource(uri); assert.ok(resource); await commands.executeCommand('fossil.add', resource); @@ -474,9 +474,9 @@ export function StageSuite(this: Suite): void { const statusSetup = async (status: string) => { const execStub = getExecStub(this.ctx.sandbox); - await fakeFossilStatus(execStub, status); + fakeFossilStatus(execStub, status); const repository = getRepository(); - await repository.updateModelState('test' as Reason); + await repository.updateStatus('Test' as Reason); }; test('Stage from working group', async () => { diff --git a/src/test/suite/statusBarSuite.ts b/src/test/suite/statusBarSuite.ts new file mode 100644 index 0000000..83587f3 --- /dev/null +++ b/src/test/suite/statusBarSuite.ts @@ -0,0 +1,248 @@ +import { window, workspace, commands } from 'vscode'; +import * as sinon from 'sinon'; +import { + fakeExecutionResult, + fakeFossilBranch, + fakeFossilChanges, + fakeFossilStatus, + fakeRawExecutionResult, + getExecStub, + getModel, + getRawExecStub, + getRepository, + statusBarCommands, +} from './common'; +import * as assert from 'assert/strict'; +import { Suite, after, before } from 'mocha'; +import { Reason } from '../../fossilExecutable'; + +export function StatusBarSuite(this: Suite): void { + let fakeTimers: sinon.SinonFakeTimers; + const N = new Date('2024-11-23T16:51:31.000Z'); + + before(() => { + fakeTimers = sinon.useFakeTimers({ + now: N, + shouldClearNativeTimers: true, + }); + }); + + after(() => { + fakeTimers.restore(); + }); + + test('Status Bar Exists', async () => { + const [branchBar, syncBar] = statusBarCommands(); + assert.equal(branchBar.command, 'fossil.branchChange'); + assert.equal(branchBar.title, '$(git-branch) trunk'); + assert.equal(branchBar.tooltip?.split('\n').pop(), 'Change Branch...'); + assert.deepEqual(branchBar.arguments, [getRepository()]); + assert.equal(syncBar.command, 'fossil.update'); + assert.equal(syncBar.title, '$(sync)'); + assert.ok(syncBar.tooltip); + assert.match( + syncBar.tooltip, + /^Next sync \d\d:\d\d:\d\d\nNone\. Already up-to-date\nUpdate$/ + ); + assert.deepEqual(syncBar.arguments, [getRepository()]); + }); + + test('Sync', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const syncCall = execStub + .withArgs(['sync']) + .resolves(fakeExecutionResult()); + const changesCall = fakeFossilChanges(execStub, '18 files modified.'); + await commands.executeCommand('fossil.sync'); + sinon.assert.calledOnceWithExactly(syncCall, ['sync']); + sinon.assert.calledOnceWithExactly( + changesCall, + ['update', '--dry-run', '--latest'], + 'Triggered by previous operation' as Reason, + { logErrors: false } + ); + const nextSyncString = new Date(N.getTime() + 3 * 60 * 1000) + .toTimeString() + .split(' ')[0]; + + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync) 18'); + assert.equal( + syncBar.tooltip, + `Next sync ${nextSyncString}\n18 files modified.\nUpdate` + ); + }); + + test('Icon spins when sync is in progress', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const syncStub = execStub.withArgs(['sync']).callsFake(async () => { + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync~spin)'); + return fakeExecutionResult(); + }); + const changeStub = fakeFossilChanges( + execStub, + 'None. Already up-to-date' + ); + await commands.executeCommand('fossil.sync'); + sinon.assert.calledOnce(syncStub); + sinon.assert.calledOnce(changeStub); + sinon.assert.calledTwice(execStub); + }); + + test('Icon spins when update is in progress', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const syncStub = execStub.withArgs(['update']).callsFake(async () => { + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync~spin)'); + return fakeExecutionResult(); + }); + const changeStub = fakeFossilChanges( + execStub, + 'None. Already up-to-date' + ); + const statusStub = fakeFossilStatus(execStub, ''); + const branchStub = fakeFossilBranch(execStub, 'trunk'); + await commands.executeCommand('fossil.update'); + sinon.assert.calledOnce(syncStub); + sinon.assert.calledOnce(changeStub); + sinon.assert.calledOnce(statusStub); + sinon.assert.calledOnce(branchStub); + sinon.assert.callCount(execStub, 4); + }); + + test('Error in tooltip when `sync` failed', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const syncStub = execStub + .withArgs(['sync']) + .resolves( + fakeExecutionResult({ stderr: 'test failure', exitCode: 1 }) + ); + const changeStub = fakeFossilChanges( + execStub, + 'None. Already up-to-date' + ); + await commands.executeCommand('fossil.sync'); + sinon.assert.calledOnce(syncStub); + sinon.assert.notCalled(changeStub); // sync failed, nothing changed + sinon.assert.calledOnce(execStub); + const syncBar = statusBarCommands()[1]; + assert.ok(syncBar.tooltip); + assert.match( + syncBar.tooltip, + /^Next sync \d\d:\d\d:\d\d\nSync error: test failure\nNone\. Already up-to-date\nUpdate$/ + ); + }); + + test('Local repository syncing', async () => { + const rawExecStub = getRawExecStub(this.ctx.sandbox); + const syncStub = rawExecStub.withArgs(['sync']).resolves( + fakeRawExecutionResult({ + stderr: 'Usage: fossil sync URL\n', + exitCode: 1, + }) + ); + const sem = this.ctx.sandbox + .stub(window, 'showErrorMessage') + .resolves(); + const execStub = getExecStub(this.ctx.sandbox); + const changeStub = fakeFossilChanges( + execStub, + 'None. Already up-to-date' + ); + + await commands.executeCommand('fossil.sync'); + sinon.assert.notCalled(changeStub); + sinon.assert.calledOnce(sem); + sinon.assert.calledOnce(syncStub); + const syncBar = statusBarCommands()[1]; + assert.ok(syncBar.tooltip); + assert.match( + syncBar.tooltip, + /^Next sync \d\d:\d\d:\d\d\nrepository with no remote\nNone\. Already up-to-date\nUpdate$/ + ); + }); + + test('Nonsensical "change" is ignored', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const syncCall = execStub + .withArgs(['sync']) + .resolves(fakeExecutionResult()); + const changesCall = execStub + .withArgs(['update', '--dry-run', '--latest']) + .resolves(fakeExecutionResult({ stdout: 'bad changes' })); + await commands.executeCommand('fossil.sync'); + sinon.assert.calledOnceWithExactly(syncCall, ['sync']); + sinon.assert.calledOnceWithExactly( + changesCall, + ['update', '--dry-run', '--latest'], + 'Triggered by previous operation' as Reason, + { logErrors: false } + ); + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync)'); + assert.ok(syncBar.tooltip); + assert.match( + syncBar.tooltip, + /^Next sync \d\d:\d\d:\d\d\nunknown changes\nUpdate$/ + ); + + // restore changes + changesCall.resolves( + fakeExecutionResult({ + stdout: 'changes: None. Already up-to-date\n', + }) + ); + await commands.executeCommand('fossil.sync'); + assert.match( + statusBarCommands()[1].tooltip!, + /^Next sync \d\d:\d\d:\d\d\nNone. Already up-to-date\nUpdate$/ + ); + }); + + const changeAutoSyncIntervalSeconds = (seconds: number) => { + const currentConfig = workspace.getConfiguration('fossil'); + const configStub = { + get: sinon.stub(), + }; + const getIntervalStub = configStub.get + .withArgs('autoSyncInterval') + .returns(seconds); + configStub.get.callsFake((key: string) => currentConfig.get(key)); + this.ctx.sandbox + .stub(workspace, 'getConfiguration') + .callThrough() + .withArgs('fossil') + .returns(configStub as any); + + const model = getModel(); + model['onDidChangeConfiguration']({ + affectsConfiguration: (key: string) => + ['fossil.autoSyncInterval', 'fossil'].includes(key), + }); + sinon.assert.calledOnce(getIntervalStub); + }; + + test('Can change `fossil.autoSyncInterval` to 5 minutes', async () => { + changeAutoSyncIntervalSeconds(5 * 60); + const nextSyncString = new Date(N.getTime() + 5 * 60 * 1000) + .toTimeString() + .split(' ')[0]; + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync)'); + assert.equal( + syncBar.tooltip, + `Next sync ${nextSyncString}\nNone. Already up-to-date\nUpdate` + ); + }); + + test('Can change `fossil.autoSyncInterval` to 0 minutes (disable)', async () => { + changeAutoSyncIntervalSeconds(0); + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync)'); + assert.equal( + syncBar.tooltip, + `Auto sync disabled\nNone. Already up-to-date\nUpdate` + ); + }); +} diff --git a/src/test/suite/utilitiesSuite.ts b/src/test/suite/utilitiesSuite.ts index 506561f..69b6c93 100644 --- a/src/test/suite/utilitiesSuite.ts +++ b/src/test/suite/utilitiesSuite.ts @@ -47,7 +47,7 @@ function undoSuite(this: Suite) { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); - await repository.updateModelState('Test' as Reason); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [[undoTxtPath, ResourceStatus.EXTRA]], });