From c4061e79dacc6bd9af059e82e02400909d9cd24f Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Thu, 21 Nov 2024 11:25:30 +0300 Subject: [PATCH 01/23] feat: new sync aware status bar * Status bar shows number of file changes that will be applied after "update"; * in-out interval setting was't intuitive, rename it to autoSyncInterval and make it really run `fossil sync` periodically; * Command can now update status/changes/branch information separately, meaning extension should run faster. + Lot's of refactoring and new code. --- docs/dev/api.md | 1 + package.json | 4 +- package.nls.json | 3 +- src/autoinout.ts | 103 -------- src/commands.ts | 6 + src/config.ts | 10 +- src/fossilExecutable.ts | 14 +- src/model.ts | 7 + src/openedRepository.ts | 16 +- src/repository.ts | 442 ++++++++++++++++++---------------- src/statusbar.ts | 379 +++++++++++------------------ src/test/suite/branchSuite.ts | 7 +- src/test/suite/commitSuite.ts | 2 + 13 files changed, 427 insertions(+), 567 deletions(-) delete mode 100644 src/autoinout.ts diff --git a/docs/dev/api.md b/docs/dev/api.md index e7b6bbc..41edcfe 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 | | • Command palette | 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..8f3bd40 100644 --- a/package.json +++ b/package.json @@ -1036,9 +1036,9 @@ "configuration": { "title": "Fossil", "properties": { - "fossil.autoInOutInterval": { + "fossil.autoSyncInterval": { "type": "number", - "description": "%config.autoInOutInterval%", + "description": "%config.autoSyncInterval%", "default": 180 }, "fossil.autoRefresh": { diff --git a/package.nls.json b/package.nls.json index 8d015da..5192413 100644 --- a/package.nls.json +++ b/package.nls.json @@ -44,8 +44,7 @@ "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": "How many seconds between each `fossil sync`", "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..77c4d5a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -109,6 +109,7 @@ type CommandKey = | 'stashPop' | 'stashSave' | 'stashSnapshot' + | 'sync' | 'undo' | 'unstage' | 'unstageAll' @@ -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..d7ae365 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( 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..44f8716 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -21,6 +21,7 @@ import { Commit, CommitDetails, ConfigKey, + Distinct, FossilBranch, FossilCheckin, FossilClass, @@ -53,7 +54,7 @@ import { } from './util'; import { memoize, throttle, debounce } from './decorators'; import { StatusBarCommands } from './statusbar'; -import typedConfig from './config'; +import typedConfig, { AutoSyncIntervalMs } from './config'; import * as path from 'path'; import { @@ -62,11 +63,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'; @@ -75,6 +71,8 @@ import { localize } from './main'; import type { ExecFailure, ExecResult, Reason } from './fossilExecutable'; const iconsRootPath = path.join(path.dirname(__dirname), 'resources', 'icons'); +export type Changes = Distinct; + type AvailableIcons = | 'status-added' | 'status-clean' @@ -195,46 +193,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 +244,49 @@ 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; } @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 { 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('opening repository' as Reason).then(() => + this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs) + ); } provideOriginalResource(uri: Uri): Uri | undefined { @@ -441,7 +403,7 @@ export class Repository implements IDisposable, InteractionAPI { @throttle async status(reason: Reason): Promise { const statusPromise = this.repository.getStatus(reason); - await this.runWithProgress(Operation.Status, () => statusPromise); + await this.runWithProgress({}, () => statusPromise); this.updateInputBoxPlaceholder(); return statusPromise; } @@ -451,7 +413,7 @@ export class Repository implements IDisposable, InteractionAPI { return; } - if (this.operations.size !== 0) { + if (this.operations_size !== 0) { return; } @@ -495,7 +457,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 +486,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 +507,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 +516,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 +532,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 +555,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) + const missingResources = resources.filter( + r => r.status === ResourceStatus.MISSING ); - await this.runWithProgress(Operation.Forget, () => - this.repository.forget(relativePaths) - ); - } - 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 +673,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 +683,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 +697,32 @@ 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, () => + return this.runWithProgress({ branch: true }, () => this.repository.newBranch(newBranch) ); } async update(checkin?: FossilCheckin): Promise { - await this.runWithProgress(Operation.Update, () => + await this.runWithProgress(UpdateChanges, () => 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,13 +880,13 @@ 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, + sideEffects: SideEffects, runOperation: () => Promise = () => Promise.resolve(null) ): Promise { if (this.state !== RepositoryState.Idle) { @@ -939,36 +896,50 @@ 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(); + const sidePromises: Promise[] = []; + if (sideEffects.status) { + sidePromises.push( + this.updateStatus( + 'Triggered by previous operation' as 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( + 'Triggered by previous operation' as Reason + ) + ); } - return result; + if (sideEffects.branch) { + sidePromises.push(this.updateBranch()); + } + await Promise.all(sidePromises); + 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(); } } ); @@ -1065,35 +1036,50 @@ 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( reason: Reason = 'model state is updating' as Reason + ): Promise { + const res = await this.updateStatus(reason); + await this.updateChanges(reason); + await this.updateBranch(); + return res; + } + + private 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; + } + } + private get count(): number { return ( this.stagingGroup.resourceStates.length + @@ -1103,6 +1089,50 @@ export class Repository implements IDisposable, InteractionAPI { ); } + public updateAutoSyncInterval(interval: AutoSyncIntervalMs): void { + clearTimeout(this.autoSyncTimer); + const nextSyncTime = new Date(Date.now() + interval); + this.statusBar.onSyncTimeUpdated(nextSyncTime); + this.autoSyncTimer = setTimeout(() => this.sync(), interval); + } + + /** + * Reads `changes` from `fossil update --dry-run --latest` + * and updates the status bar + */ + private async updateChanges(reason: Reason): Promise { + const updateOutput = await this.repository.update( + undefined, + true, + reason + ); + if (updateOutput.exitCode) { + this.statusBar.onChangesUpdated( + 'error' as Changes, + updateOutput.stderr + ); + } else { + const match = updateOutput.stdout.match(/^changes:\s*(.*)/m); + const changes = (match ? match[1] : 'unknown status') as Changes; + this.statusBar.onChangesUpdated(changes); + } + } + + /** + * runs `fossil sync`. when 'auto' is set to true, errors are ignored + * and exec reason is set to 'periodic sync'. + * after that the `changes` are updated and auto sync is rescheduled + */ + async sync(auto?: true): Promise { + await this.repository.exec( + ['sync'], + auto ? ('periodic sync' as Reason) : undefined, + { logErrors: !auto } + ); + await this.updateChanges('sync happened' as Reason); + this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs); + } + dispose(): void { dispose(this.disposables); } diff --git a/src/statusbar.ts b/src/statusbar.ts index 1ddb39b..9811ee4 100644 --- a/src/statusbar.ts +++ b/src/statusbar.ts @@ -1,262 +1,153 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (c) Ben Crowl. All rights reserved. - * Original Copyright (c) Microsoft Corporation. All rights reserved. + * Copyright (c) Arseniy Terekhin. 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 type { Command, SourceControl } from 'vscode'; +import { Repository, Changes } 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); - } - +import { FossilStdErr } 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 text = ''; + private errorText: FossilStdErr | undefined; + private changes: Changes = '' as Changes; + private nextSyncTime: Date | undefined; // undefined = no auto syncing + + constructor(private repository: Repository) {} + + /** + * @param changes like `17 files modified.` or ` None. Already up-to-date` + */ + public onChangesUpdated(changes: Changes, errorText?: FossilStdErr) { + const numChanges = changes.match(/\d+/); + this.text = numChanges && errorText === undefined ? numChanges[0] : ''; + this.changes = changes; + this.errorText = errorText; + } + + public onSyncTimeUpdated(date: Date | undefined) { + this.nextSyncTime = date; + } + + public get command(): Command { + const message = this.nextSyncTime + ? `Next sync ${this.nextSyncTime.toTimeString().split(' ')[0]}` + : `Auto sync disabled`; + const tooltip = + this.errorText === undefined + ? `${this.changes}\n${message}\nUpdate` + : `Error: ${this.errorText}`; 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], + command: 'fossil.update', + title: `$(sync) ${this.text}`.trim(), + tooltip: tooltip, + arguments: [this.repository satisfies 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, +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 \u2022 {3}\nChange Branch...', + fossilStatus!.checkout.checkin, + fossilStatus!.checkout.date, + checkoutAge && ` (${checkoutAge})`, + fossilStatus!.tags.join('\n \u2022 ') + ), + title, + arguments: [repository satisfies Repository], }; - - 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); + private readonly syncBar: SyncBar; + + constructor( + private readonly repository: Repository, + private readonly sourceControl: SourceControl + ) { + this.syncBar = new SyncBar(repository); + this.update(); + } + + public onChangesUpdated(changes: Changes, errorText?: FossilStdErr) { + this.syncBar.onChangesUpdated(changes, errorText); + this.update(); + } + + public onSyncTimeUpdated(date: Date | undefined) { + this.syncBar.onSyncTimeUpdated(date); + 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)', + }, + ]; } - - return result; - } - - dispose(): void { - this.syncStatusBar.dispose(); - this.scopeStatusBar.dispose(); - dispose(this.disposables); + this.sourceControl.statusBarCommands = commands; } } 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/commitSuite.ts b/src/test/suite/commitSuite.ts index a094e1b..3750135 100644 --- a/src/test/suite/commitSuite.ts +++ b/src/test/suite/commitSuite.ts @@ -34,7 +34,9 @@ export const commitStagedTest = async ( .withArgs(sinon.match.array.startsWith(['commit'])) .resolves(fakeExecutionResult()); await repository.updateModelState(); + 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); From d8acedf477d52498a98887108cd1fd1807df3f13 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sat, 23 Nov 2024 12:16:12 +0300 Subject: [PATCH 02/23] refactor: use `updateModelState` to apply side effects --- src/model.ts | 4 +- src/repository.ts | 76 +++++++++++--------------- src/test/suite/commandSuites.ts | 18 +++--- src/test/suite/commitSuite.ts | 22 ++++---- src/test/suite/common.ts | 2 +- src/test/suite/mergeSuite.ts | 10 ++-- src/test/suite/renameSuite.ts | 6 +- src/test/suite/resourceActionsSuite.ts | 22 ++++---- src/test/suite/revertSuite.ts | 7 ++- src/test/suite/stateSuite.ts | 6 +- src/test/suite/utilitiesSuite.ts | 2 +- 11 files changed, 82 insertions(+), 93 deletions(-) diff --git a/src/model.ts b/src/model.ts index d7ae365..6f4c43e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -350,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/repository.ts b/src/repository.ts index 44f8716..bac3f57 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -388,8 +388,8 @@ export class Repository implements IDisposable, InteractionAPI { this.statusBar, this.disposables ); - this.updateModelState('opening repository' as Reason).then(() => - this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs) + this.updateModelState(UpdateAll, 'opening repository' as Reason).then( + () => this.updateAutoSyncInterval(typedConfig.autoSyncIntervalMs) ); } @@ -428,7 +428,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); } @@ -902,40 +902,10 @@ export class Repository implements IDisposable, InteractionAPI { try { const operationResult = await runOperation(); - const sidePromises: Promise[] = []; - if (sideEffects.status) { - sidePromises.push( - this.updateStatus( - 'Triggered by previous operation' as 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( - 'Triggered by previous operation' as Reason - ) - ); - } - if (sideEffects.branch) { - sidePromises.push(this.updateBranch()); - } - await Promise.all(sidePromises); + await this.updateModelState( + sideEffects, + 'Triggered by previous operation' as Reason + ); return operationResult; } finally { this._operations.delete(key); @@ -1045,15 +1015,35 @@ export class Repository implements IDisposable, InteractionAPI { */ @throttle public async updateModelState( + sideEffects: SideEffects, reason: Reason = 'model state is updating' as Reason - ): Promise { - const res = await this.updateStatus(reason); - await this.updateChanges(reason); - await this.updateBranch(); - return res; + ): 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); } - private async updateStatus( + public async updateStatus( reason?: Reason ): Promise { const result = await this.repository.getStatus(reason); diff --git a/src/test/suite/commandSuites.ts b/src/test/suite/commandSuites.ts index a9f33e6..244103d 100644 --- a/src/test/suite/commandSuites.ts +++ b/src/test/suite/commandSuites.ts @@ -46,7 +46,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 +58,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 +96,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 +174,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], @@ -196,7 +196,7 @@ export function StatusSuite(this: Suite): void { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); await fakeFossilStatus(execStub, status); - await repository.updateModelState(); + 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); @@ -317,7 +317,7 @@ export function CleanSuite(this: Suite): void { .withArgs(sinon.match.array.startsWith(['clean'])) .resolves(); await fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt'); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [ [Uri.joinPath(rootUri, 'a.txt').fsPath, ResourceStatus.EXTRA], @@ -358,7 +358,7 @@ export function CleanSuite(this: Suite): void { execStub, 'EXTRA a.txt\nEXTRA b.txt\nEXTRA c.txt' ); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [ [Uri.joinPath(rootUri, 'a.txt').fsPath, ResourceStatus.EXTRA], @@ -429,7 +429,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 3750135..6d2f0de 100644 --- a/src/test/suite/commitSuite.ts +++ b/src/test/suite/commitSuite.ts @@ -33,7 +33,7 @@ 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); @@ -62,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: [ @@ -98,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: [ @@ -143,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', @@ -173,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, {}); @@ -195,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]], }); @@ -234,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); @@ -272,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); @@ -330,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 @@ -354,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') @@ -392,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..1d4c645 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -317,7 +317,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); diff --git a/src/test/suite/mergeSuite.ts b/src/test/suite/mergeSuite.ts index acebd87..761bcc9 100644 --- a/src/test/suite/mergeSuite.ts +++ b/src/test/suite/mergeSuite.ts @@ -102,7 +102,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); // ToDo: after refresh??? assertGroups(repository, {}); const sqp: sinon.SinonStub = this.ctx.sandbox.stub( @@ -119,7 +119,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 +129,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 +154,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 +208,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..f277840 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]], @@ -168,7 +168,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 +192,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..3c6b38b 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: [ @@ -139,7 +139,7 @@ export function resourceActionsSuite(this: Suite): void { execStub, 'ADDED a.txt\nEDITED b.txt\nEXTRA c.txt' ); - await repository.updateModelState(); + await repository.updateStatus('Test' as Reason); await commands.executeCommand( 'fossil.forget', ...repository.workingGroup.resourceStates @@ -180,7 +180,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 +200,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 +230,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 +259,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 +289,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 +377,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..d9af6df 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -173,7 +173,7 @@ export function UpdateSuite(this: Suite): void { const execStub = getExecStub(this.ctx.sandbox); await 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); @@ -476,7 +476,7 @@ export function StageSuite(this: Suite): void { const execStub = getExecStub(this.ctx.sandbox); await 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/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]], }); From 89cdc314f81e7c0078a059a5f658a77d2e08a211 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sat, 23 Nov 2024 13:20:27 +0300 Subject: [PATCH 03/23] fix: "refresh" command explicitly refreshes all --- src/commands.ts | 4 ++-- src/repository.ts | 8 +++----- src/test/suite/mergeSuite.ts | 1 - 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 77c4d5a..3f2cc27 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'; @@ -197,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() diff --git a/src/repository.ts b/src/repository.ts index bac3f57..40ed761 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -401,11 +401,8 @@ export class Repository implements IDisposable, InteractionAPI { } @throttle - async status(reason: Reason): Promise { - const statusPromise = this.repository.getStatus(reason); - await this.runWithProgress({}, () => statusPromise); - this.updateInputBoxPlaceholder(); - return statusPromise; + async refresh(): Promise { + await this.runWithProgress(UpdateAll, () => Promise.resolve()); } private onFSChange(_uri: Uri): void { @@ -1068,6 +1065,7 @@ export class Repository implements IDisposable, InteractionAPI { if (this._currentBranch !== currentBranch) { this._currentBranch = currentBranch; } + this.updateInputBoxPlaceholder(); } private get count(): number { diff --git a/src/test/suite/mergeSuite.ts b/src/test/suite/mergeSuite.ts index 761bcc9..0b5b065 100644 --- a/src/test/suite/mergeSuite.ts +++ b/src/test/suite/mergeSuite.ts @@ -102,7 +102,6 @@ export function MergeSuite(this: Suite): void { await openedRepository.exec(['update', 'trunk']); await commands.executeCommand('fossil.refresh'); - await repository.updateStatus('Test' as Reason); // ToDo: after refresh??? assertGroups(repository, {}); const sqp: sinon.SinonStub = this.ctx.sandbox.stub( From 92ca9487a674ba24de1920e7b1eb78c6598b41a0 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sat, 23 Nov 2024 18:14:11 +0300 Subject: [PATCH 04/23] test: fix: `fakeFossilStatus` should't be awaited --- src/test/suite/commandSuites.ts | 9 +++------ src/test/suite/renameSuite.ts | 5 +---- src/test/suite/resourceActionsSuite.ts | 5 +---- src/test/suite/stateSuite.ts | 4 ++-- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/test/suite/commandSuites.ts b/src/test/suite/commandSuites.ts index 244103d..aa009e4 100644 --- a/src/test/suite/commandSuites.ts +++ b/src/test/suite/commandSuites.ts @@ -195,7 +195,7 @@ export function StatusSuite(this: Suite): void { ) => { const repository = getRepository(); const execStub = getExecStub(this.ctx.sandbox); - await fakeFossilStatus(execStub, status); + fakeFossilStatus(execStub, status); await repository.updateStatus('Test' as Reason); const root = vscode.workspace.workspaceFolders![0].uri; const uriBefore = Uri.joinPath(root, before); @@ -316,7 +316,7 @@ 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'); + fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt'); await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [ @@ -354,10 +354,7 @@ 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' - ); + fakeFossilStatus(execStub, 'EXTRA a.txt\nEXTRA b.txt\nEXTRA c.txt'); await repository.updateStatus('Test' as Reason); assertGroups(repository, { untracked: [ diff --git a/src/test/suite/renameSuite.ts b/src/test/suite/renameSuite.ts index f277840..f4b92de 100644 --- a/src/test/suite/renameSuite.ts +++ b/src/test/suite/renameSuite.ts @@ -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( diff --git a/src/test/suite/resourceActionsSuite.ts b/src/test/suite/resourceActionsSuite.ts index 3c6b38b..6aa9c51 100644 --- a/src/test/suite/resourceActionsSuite.ts +++ b/src/test/suite/resourceActionsSuite.ts @@ -135,10 +135,7 @@ 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' - ); + fakeFossilStatus(execStub, 'ADDED a.txt\nEDITED b.txt\nEXTRA c.txt'); await repository.updateStatus('Test' as Reason); await commands.executeCommand( 'fossil.forget', diff --git a/src/test/suite/stateSuite.ts b/src/test/suite/stateSuite.ts index d9af6df..65b2911 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -171,7 +171,7 @@ 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.updateStatus('Test' as Reason); assert.ok(repository.fossilStatus?.isMerge); @@ -474,7 +474,7 @@ 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.updateStatus('Test' as Reason); }; From 02059cf7163ac83748f0dee96e3a3f61546823df Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sat, 23 Nov 2024 18:15:28 +0300 Subject: [PATCH 05/23] test: new test for `fossil.refresh` --- src/test/suite/commandSuites.ts | 14 ++++++++++++++ src/test/suite/common.ts | 18 +++++++++++++++--- src/test/suite/mergeSuite.ts | 2 +- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/test/suite/commandSuites.ts b/src/test/suite/commandSuites.ts index aa009e4..ba46361 100644 --- a/src/test/suite/commandSuites.ts +++ b/src/test/suite/commandSuites.ts @@ -5,6 +5,8 @@ import { add, assertGroups, cleanupFossil, + fakeFossilBranch, + fakeFossilChanges, fakeFossilStatus, fakeRawExecutionResult, getExecStub, @@ -238,6 +240,18 @@ 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 changes'); + await commands.executeCommand('fossil.refresh'); + sinon.assert.calledThrice(execStub); + sinon.assert.calledOnce(status); + sinon.assert.calledOnce(branch); + sinon.assert.calledOnce(changes); + }); } export function TagSuite(this: Suite): void { diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index 1d4c645..836379c 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -179,14 +179,26 @@ export function fakeFossilStatus(execStub: ExecStub, status: string): ExecStub { '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 - .withArgs(['branch', 'current']) - .resolves(fakeExecutionResult({ stdout: 'trunk' })); return execStub .withArgs(args) .resolves(fakeExecutionResult({ stdout: header + status, args })); } +export function fakeFossilBranch(execStub: ExecStub, branch: string): ExecStub { + return execStub + .withArgs(['branch', 'current']) + .resolves(fakeExecutionResult({ stdout: branch })); +} + +export function fakeFossilChanges( + execStub: ExecStub, + changes: string +): ExecStub { + return execStub + .withArgs(['update', '--dry-run', '--latest']) + .resolves(fakeExecutionResult({ stdout: `changes: ${changes}\n` })); +} + async function setupFossilOpen(sandbox: sinon.SinonSandbox) { assert.ok(vscode.workspace.workspaceFolders); const rootPath = vscode.workspace.workspaceFolders[0].uri; diff --git a/src/test/suite/mergeSuite.ts b/src/test/suite/mergeSuite.ts index 0b5b065..d85acfd 100644 --- a/src/test/suite/mergeSuite.ts +++ b/src/test/suite/mergeSuite.ts @@ -101,7 +101,7 @@ export function MergeSuite(this: Suite): void { ]); await openedRepository.exec(['update', 'trunk']); - await commands.executeCommand('fossil.refresh'); + await repository.updateStatus('Test' as Reason); assertGroups(repository, {}); const sqp: sinon.SinonStub = this.ctx.sandbox.stub( From 1b431a0cfd231a0f07094d045d3440181369eb85 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Mon, 25 Nov 2024 11:06:40 +0300 Subject: [PATCH 06/23] refactor: replace "\u2022" with bullet character --- src/statusbar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/statusbar.ts b/src/statusbar.ts index 9811ee4..98cb733 100644 --- a/src/statusbar.ts +++ b/src/statusbar.ts @@ -77,11 +77,11 @@ function branchCommand(repository: Repository): Command { command: 'fossil.branchChange', tooltip: localize( 'branch change {0} {1}{2} {3}', - '{0}\n{1}{2}\nTags:\n \u2022 {3}\nChange Branch...', + '{0}\n{1}{2}\nTags:\n • {3}\nChange Branch...', fossilStatus!.checkout.checkin, fossilStatus!.checkout.date, checkoutAge && ` (${checkoutAge})`, - fossilStatus!.tags.join('\n \u2022 ') + fossilStatus!.tags.join('\n • ') ), title, arguments: [repository satisfies Repository], From 1c67fbaef7efa8e65583c58900f25ddc9ea6bfd2 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Mon, 25 Nov 2024 11:08:26 +0300 Subject: [PATCH 07/23] comment: add comment about `branchCommand` --- src/statusbar.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/statusbar.ts b/src/statusbar.ts index 98cb733..ae022a9 100644 --- a/src/statusbar.ts +++ b/src/statusbar.ts @@ -57,6 +57,11 @@ class SyncBar { } } +/** + * 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)'; From 13b3abf8affee8bfbe7344aba4c1338ef2f89811 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Mon, 25 Nov 2024 11:09:30 +0300 Subject: [PATCH 08/23] test: add first status bar tests --- src/test/suite/commandSuites.ts | 2 +- src/test/suite/common.ts | 14 ++++++-- src/test/suite/stateSuite.ts | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/test/suite/commandSuites.ts b/src/test/suite/commandSuites.ts index ba46361..a6191b3 100644 --- a/src/test/suite/commandSuites.ts +++ b/src/test/suite/commandSuites.ts @@ -245,7 +245,7 @@ export function StatusSuite(this: Suite): void { const execStub = getExecStub(this.ctx.sandbox); const status = fakeFossilStatus(execStub, 'EXTRA refresh.txt\n'); const branch = fakeFossilBranch(execStub, 'refresh'); - const changes = fakeFossilChanges(execStub, '12 changes'); + const changes = fakeFossilChanges(execStub, '12 files modified.'); await commands.executeCommand('fossil.refresh'); sinon.assert.calledThrice(execStub); sinon.assert.calledOnce(status); diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index 836379c..713b6ce 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -184,7 +184,10 @@ export function fakeFossilStatus(execStub: ExecStub, status: string): ExecStub { .resolves(fakeExecutionResult({ stdout: header + status, args })); } -export function fakeFossilBranch(execStub: ExecStub, branch: string): ExecStub { +export function fakeFossilBranch( + execStub: ExecStub, + branch: 'refresh' +): ExecStub { return execStub .withArgs(['branch', 'current']) .resolves(fakeExecutionResult({ stdout: branch })); @@ -192,7 +195,7 @@ export function fakeFossilBranch(execStub: ExecStub, branch: string): ExecStub { export function fakeFossilChanges( execStub: ExecStub, - changes: string + changes: `${number} files modified.` | 'None. Already up-to-date' ): ExecStub { return execStub .withArgs(['update', '--dry-run', '--latest']) @@ -380,3 +383,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/stateSuite.ts b/src/test/suite/stateSuite.ts index 65b2911..d4ec357 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -4,9 +4,11 @@ import { assertGroups, cleanupFossil, fakeExecutionResult, + fakeFossilChanges, fakeFossilStatus, getExecStub, getRepository, + statusBarCommands, } from './common'; import * as assert from 'assert/strict'; import * as fs from 'fs/promises'; @@ -148,8 +150,66 @@ function PullAndPushSuite(this: Suite): void { }); } +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.match( + syncBar.tooltip!, + /^None. Already up-to-date\nNext sync \d\d:\d\d:\d\d\nUpdate$/ + ); + assert.deepEqual(syncBar.arguments, [getRepository()]); + }); + + test('Sync', async () => { + const execStub = getExecStub(this.ctx.sandbox); + const syncCall = execStub.withArgs(['sync']).resolves(); + const changesCall = fakeFossilChanges(execStub, '18 files modified.'); + await commands.executeCommand('fossil.sync'); + sinon.assert.calledOnceWithExactly(syncCall, ['sync'], undefined, { + logErrors: true, + }); + sinon.assert.calledOnceWithExactly( + changesCall, + ['update', '--dry-run', '--latest'], + 'sync happened' as Reason, + { logErrors: false } + ); + const nextSyncString = new Date(N.getTime() + 180 * 1000) + .toTimeString() + .split(' ')[0]; + + const syncBar = statusBarCommands()[1]; + assert.equal(syncBar.title, '$(sync) 18'); + assert.equal( + syncBar.tooltip, + `18 files modified.\nNext sync ${nextSyncString}\nUpdate` + ); + }); +} + export function UpdateSuite(this: Suite): void { suite('Pull and Push', PullAndPushSuite); + suite('Status Bar', StatusBarSuite); test('Change branch to trunk', async () => { const execStub = getExecStub(this.ctx.sandbox); From f76d727a884ebb29c3049a186f15b7c7276f499b Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Tue, 26 Nov 2024 22:55:44 +0300 Subject: [PATCH 09/23] fix: await update in `branchChange` --- src/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands.ts b/src/commands.ts index 3f2cc27..edfcc01 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1119,7 +1119,7 @@ export class CommandCenter { const checkin = await interaction.pickUpdateCheckin(refs); if (checkin) { - repository.update(checkin); + await repository.update(checkin); } } From 4153e641edcfc56030f3c1a9301ad4742cb5da04 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Tue, 26 Nov 2024 23:04:46 +0300 Subject: [PATCH 10/23] fix: make 'update' command affect branch and changes --- src/repository.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/repository.ts b/src/repository.ts index 40ed761..7a5c6ef 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -707,13 +707,15 @@ export class Repository implements IDisposable, InteractionAPI { } async newBranch(newBranch: NewBranchOptions): Promise { - return this.runWithProgress({ branch: true }, () => + // Creating a new branch doesn't change anything. + return this.runWithProgress({}, () => this.repository.newBranch(newBranch) ); } async update(checkin?: FossilCheckin): Promise { - await this.runWithProgress(UpdateChanges, () => + // Update command can change everything + await this.runWithProgress(UpdateAll, () => this.repository.update(checkin) ); } From c5ad642215010fa1933043a42b7576d93221622e Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Tue, 26 Nov 2024 23:09:27 +0300 Subject: [PATCH 11/23] test: add "Branch change is reflected in status bar" test --- src/test/suite/commandSuites.ts | 81 +++++++++++++++++++++++++++++++++ src/test/suite/common.ts | 12 +++-- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/test/suite/commandSuites.ts b/src/test/suite/commandSuites.ts index a6191b3..7005adf 100644 --- a/src/test/suite/commandSuites.ts +++ b/src/test/suite/commandSuites.ts @@ -5,13 +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'; @@ -251,7 +254,85 @@ export function StatusSuite(this: Suite): void { 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 { diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index 713b6ce..a2b7b52 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -173,15 +173,19 @@ 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']; + return fakeExecutionResult({ stdout: header + status, args }); +} + +export function fakeFossilStatus(execStub: ExecStub, status: string): ExecStub { return execStub - .withArgs(args) - .resolves(fakeExecutionResult({ stdout: header + status, args })); + .withArgs(['status', '--differ', '--merge']) + .resolves(fakeStatusResult(status)); } export function fakeFossilBranch( From f7ae856475910f640bb3424910e8c3dc23afd26a Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Wed, 27 Nov 2024 11:23:32 +0300 Subject: [PATCH 12/23] fix: don't require opened repository for preview to work --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8f3bd40..a2b58f6 100644 --- a/package.json +++ b/package.json @@ -571,7 +571,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", From 611deef58f3802e1037caa0b378b0808bf772a52 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 16:02:20 +0300 Subject: [PATCH 13/23] fix: missing 'sync' command in the menus --- docs/dev/api.md | 2 +- package.json | 16 +++++++++++++--- package.nls.json | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/dev/api.md b/docs/dev/api.md index 41edcfe..aa6c3f4 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -58,7 +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 | | • 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 a2b58f6..d5f7bc5 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%", @@ -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", diff --git a/package.nls.json b/package.nls.json index 5192413..f420ab5 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", From 3ba35b273caa59f6fcb491b8b60b8b2fe2886681 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 16:15:01 +0300 Subject: [PATCH 14/23] refactor: `string` to `RelativePath` --- src/repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/repository.ts b/src/repository.ts index 7a5c6ef..60c7162 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -312,7 +312,7 @@ export class Repository implements IDisposable, InteractionAPI { return this._operations; } - toUri(rawPath: string): Uri { + toUri(rawPath: RelativePath): Uri { return Uri.file(path.join(this.repository.root, rawPath)); } @@ -934,7 +934,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); From 22e999b1e8abb3086e43f1b738ec7bf914f5103b Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 16:53:56 +0300 Subject: [PATCH 15/23] fix: rework sync bar moments Let the `SyncBar` decide what to do with the fossil output, thus simplifying logic separation. Plus add tests. --- src/repository.ts | 64 ++++++++++++++--------------- src/statusbar.ts | 63 ++++++++++++++++++---------- src/test/suite/common.ts | 2 +- src/test/suite/stateSuite.ts | 79 ++++++++++++++++++++++++++++++++---- 4 files changed, 145 insertions(+), 63 deletions(-) diff --git a/src/repository.ts b/src/repository.ts index 60c7162..061172b 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -21,7 +21,6 @@ import { Commit, CommitDetails, ConfigKey, - Distinct, FossilBranch, FossilCheckin, FossilClass, @@ -71,8 +70,6 @@ import { localize } from './main'; import type { ExecFailure, ExecResult, Reason } from './fossilExecutable'; const iconsRootPath = path.join(path.dirname(__dirname), 'resources', 'icons'); -export type Changes = Distinct; - type AvailableIcons = | 'status-added' | 'status-clean' @@ -279,6 +276,9 @@ export class Repository implements IDisposable, InteractionAPI { return this._sourceControl; } + /** + * An operation started or stopped running + */ @memoize private get onDidChangeOperations(): Event { return anyEvent( @@ -715,8 +715,9 @@ export class Repository implements IDisposable, InteractionAPI { async update(checkin?: FossilCheckin): Promise { // Update command can change everything - await this.runWithProgress(UpdateAll, () => - this.repository.update(checkin) + await this.runWithProgress( + { syncText: 'Updating...', ...UpdateAll }, + () => this.repository.update(checkin) ); } @@ -886,7 +887,8 @@ export class Repository implements IDisposable, InteractionAPI { private async runWithProgress( sideEffects: SideEffects, - runOperation: () => Promise = () => Promise.resolve(null) + runOperation: () => Promise = () => Promise.resolve(null), + runSideEffects: (arg0: T) => boolean = () => true ): Promise { if (this.state !== RepositoryState.Idle) { throw new Error('Repository not initialized'); @@ -901,10 +903,12 @@ export class Repository implements IDisposable, InteractionAPI { try { const operationResult = await runOperation(); - await this.updateModelState( - sideEffects, - 'Triggered by previous operation' as Reason - ); + if (runSideEffects(operationResult)) { + await this.updateModelState( + sideEffects, + 'Triggered by previous operation' as Reason + ); + } return operationResult; } finally { this._operations.delete(key); @@ -1083,7 +1087,7 @@ export class Repository implements IDisposable, InteractionAPI { clearTimeout(this.autoSyncTimer); const nextSyncTime = new Date(Date.now() + interval); this.statusBar.onSyncTimeUpdated(nextSyncTime); - this.autoSyncTimer = setTimeout(() => this.sync(), interval); + this.autoSyncTimer = setTimeout(() => this.periodicSync(), interval); } /** @@ -1091,38 +1095,32 @@ export class Repository implements IDisposable, InteractionAPI { * and updates the status bar */ private async updateChanges(reason: Reason): Promise { - const updateOutput = await this.repository.update( + const updateResult = await this.repository.update( undefined, true, reason ); - if (updateOutput.exitCode) { - this.statusBar.onChangesUpdated( - 'error' as Changes, - updateOutput.stderr - ); - } else { - const match = updateOutput.stdout.match(/^changes:\s*(.*)/m); - const changes = (match ? match[1] : 'unknown status') as Changes; - this.statusBar.onChangesUpdated(changes); - } + this.statusBar.onChangesReady(updateResult); } - /** - * runs `fossil sync`. when 'auto' is set to true, errors are ignored - * and exec reason is set to 'periodic sync'. - * after that the `changes` are updated and auto sync is rescheduled - */ - async sync(auto?: true): Promise { - await this.repository.exec( - ['sync'], - auto ? ('periodic sync' as Reason) : undefined, - { logErrors: !auto } - ); + 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 index ae022a9..7380916 100644 --- a/src/statusbar.ts +++ b/src/statusbar.ts @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import type { Command, SourceControl } from 'vscode'; -import { Repository, Changes } from './repository'; +import { Repository } from './repository'; import { ageFromNow, Old } from './humanise'; import { localize } from './main'; -import { FossilStdErr } from './fossilExecutable'; +import { ExecResult } from './fossilExecutable'; /** * A bar with 'sync' icon; @@ -19,21 +19,41 @@ import { FossilStdErr } from './fossilExecutable'; * - 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 errorText: FossilStdErr | undefined; - private changes: Changes = '' as Changes; + 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) {} - /** - * @param changes like `17 files modified.` or ` None. Already up-to-date` - */ - public onChangesUpdated(changes: Changes, errorText?: FossilStdErr) { - const numChanges = changes.match(/\d+/); - this.text = numChanges && errorText === undefined ? numChanges[0] : ''; - this.changes = changes; - this.errorText = errorText; + 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) { @@ -41,17 +61,13 @@ class SyncBar { } public get command(): Command { - const message = this.nextSyncTime + const timeMessage = this.nextSyncTime ? `Next sync ${this.nextSyncTime.toTimeString().split(' ')[0]}` : `Auto sync disabled`; - const tooltip = - this.errorText === undefined - ? `${this.changes}\n${message}\nUpdate` - : `Error: ${this.errorText}`; return { command: 'fossil.update', - title: `$(sync) ${this.text}`.trim(), - tooltip: tooltip, + title: `$(${this.icon}) ${this.text}`.trim(), + tooltip: `${timeMessage}\n${this.syncMessage}${this.changes}\nUpdate`, arguments: [this.repository satisfies Repository], }; } @@ -104,8 +120,8 @@ export class StatusBarCommands { this.update(); } - public onChangesUpdated(changes: Changes, errorText?: FossilStdErr) { - this.syncBar.onChangesUpdated(changes, errorText); + public onChangesReady(updateResult: ExecResult) { + this.syncBar.onChangesReady(updateResult); this.update(); } @@ -114,6 +130,11 @@ export class StatusBarCommands { this.update(); } + public onSyncReady(syncResult: ExecResult) { + this.syncBar.onSyncReady(syncResult); + this.update(); + } + /** * Should be called whenever commands text/actions/tooltips * are updated diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index a2b7b52..0bbf768 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -190,7 +190,7 @@ export function fakeFossilStatus(execStub: ExecStub, status: string): ExecStub { export function fakeFossilBranch( execStub: ExecStub, - branch: 'refresh' + branch: 'refresh' | 'trunk' ): ExecStub { return execStub .withArgs(['branch', 'current']) diff --git a/src/test/suite/stateSuite.ts b/src/test/suite/stateSuite.ts index d4ec357..ecf1182 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -4,6 +4,7 @@ import { assertGroups, cleanupFossil, fakeExecutionResult, + fakeFossilBranch, fakeFossilChanges, fakeFossilStatus, getExecStub, @@ -173,25 +174,26 @@ export function StatusBarSuite(this: Suite): void { assert.deepEqual(branchBar.arguments, [getRepository()]); assert.equal(syncBar.command, 'fossil.update'); assert.equal(syncBar.title, '$(sync)'); + assert.ok(syncBar.tooltip); assert.match( - syncBar.tooltip!, - /^None. Already up-to-date\nNext sync \d\d:\d\d:\d\d\nUpdate$/ + 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(); + const syncCall = execStub + .withArgs(['sync']) + .resolves(fakeExecutionResult()); const changesCall = fakeFossilChanges(execStub, '18 files modified.'); await commands.executeCommand('fossil.sync'); - sinon.assert.calledOnceWithExactly(syncCall, ['sync'], undefined, { - logErrors: true, - }); + sinon.assert.calledOnceWithExactly(syncCall, ['sync']); sinon.assert.calledOnceWithExactly( changesCall, ['update', '--dry-run', '--latest'], - 'sync happened' as Reason, + 'Triggered by previous operation' as Reason, { logErrors: false } ); const nextSyncString = new Date(N.getTime() + 180 * 1000) @@ -202,7 +204,68 @@ export function StatusBarSuite(this: Suite): void { assert.equal(syncBar.title, '$(sync) 18'); assert.equal( syncBar.tooltip, - `18 files modified.\nNext sync ${nextSyncString}\nUpdate` + `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/ ); }); } From 10f82898e2a141f8b1c8ec66ebe516c64e02edf6 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 17:58:09 +0300 Subject: [PATCH 16/23] test: add local repository syncing test --- src/test/suite/stateSuite.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/test/suite/stateSuite.ts b/src/test/suite/stateSuite.ts index ecf1182..d63a7e6 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -7,7 +7,9 @@ import { fakeFossilBranch, fakeFossilChanges, fakeFossilStatus, + fakeRawExecutionResult, getExecStub, + getRawExecStub, getRepository, statusBarCommands, } from './common'; @@ -268,6 +270,35 @@ export function StatusBarSuite(this: Suite): void { /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/ + ); + }); } export function UpdateSuite(this: Suite): void { From eb9d91a13791f53c7a1f8c6b9109025d6c9dd8df Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 18:01:14 +0300 Subject: [PATCH 17/23] refactor: remove default "runOperation" It was never used --- src/repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repository.ts b/src/repository.ts index 061172b..ff1e3f4 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -887,7 +887,7 @@ export class Repository implements IDisposable, InteractionAPI { private async runWithProgress( sideEffects: SideEffects, - runOperation: () => Promise = () => Promise.resolve(null), + runOperation: () => Promise, runSideEffects: (arg0: T) => boolean = () => true ): Promise { if (this.state !== RepositoryState.Idle) { From bbc5c02c838acece690b0824b8ac59587143f9fa Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 18:24:18 +0300 Subject: [PATCH 18/23] tets: add 'nonsensical "change" is ignored' test just to increase code coverage --- src/test/suite/stateSuite.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/suite/stateSuite.ts b/src/test/suite/stateSuite.ts index d63a7e6..f060b89 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -299,6 +299,31 @@ export function StatusBarSuite(this: Suite): void { /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/ + ); + }); } export function UpdateSuite(this: Suite): void { From 28f77b25a3ac6fce20cef4f89e1ae9f928df5dca Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 21:28:41 +0300 Subject: [PATCH 19/23] fix: update "autoSyncInterval" docs --- README.md | 6 +++++- package.nls.json | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) 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/package.nls.json b/package.nls.json index f420ab5..8fc7715 100644 --- a/package.nls.json +++ b/package.nls.json @@ -42,10 +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.autoSyncInterval": "How many seconds between each `fossil sync`", + "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", From 2dd46f36dc14987785e6a0dbce44bfe8f7dbf2b0 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 21:34:41 +0300 Subject: [PATCH 20/23] fix: allow turning off automatic 'fossil sync' And add 2 related tests --- package.json | 3 +- src/repository.ts | 14 ++++++-- src/test/suite/common.ts | 13 ++++--- src/test/suite/stateSuite.ts | 67 +++++++++++++++++++++++++++++++++--- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index d5f7bc5..dd8e4de 100644 --- a/package.json +++ b/package.json @@ -1049,7 +1049,8 @@ "fossil.autoSyncInterval": { "type": "number", "description": "%config.autoSyncInterval%", - "default": 180 + "default": 180, + "minimum": 0 }, "fossil.autoRefresh": { "type": "boolean", diff --git a/src/repository.ts b/src/repository.ts index ff1e3f4..dcbd83b 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -1085,9 +1085,19 @@ export class Repository implements IDisposable, InteractionAPI { public updateAutoSyncInterval(interval: AutoSyncIntervalMs): void { clearTimeout(this.autoSyncTimer); - const nextSyncTime = new Date(Date.now() + interval); + // 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); - this.autoSyncTimer = setTimeout(() => this.periodicSync(), interval); + if (interval) { + this.autoSyncTimer = setTimeout( + () => this.periodicSync(), + interval + ); + } } /** diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index 0bbf768..de57b70 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; diff --git a/src/test/suite/stateSuite.ts b/src/test/suite/stateSuite.ts index f060b89..a94dc9d 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -9,6 +9,7 @@ import { fakeFossilStatus, fakeRawExecutionResult, getExecStub, + getModel, getRawExecStub, getRepository, statusBarCommands, @@ -198,7 +199,7 @@ export function StatusBarSuite(this: Suite): void { 'Triggered by previous operation' as Reason, { logErrors: false } ); - const nextSyncString = new Date(N.getTime() + 180 * 1000) + const nextSyncString = new Date(N.getTime() + 3 * 60 * 1000) .toTimeString() .split(' ')[0]; @@ -267,7 +268,7 @@ export function StatusBarSuite(this: Suite): void { 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/ + /^Next sync \d\d:\d\d:\d\d\nSync error: test failure\nNone\. Already up-to-date\nUpdate$/ ); }); @@ -296,7 +297,7 @@ export function StatusBarSuite(this: Suite): void { 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/ + /^Next sync \d\d:\d\d:\d\d\nrepository with no remote\nNone\. Already up-to-date\nUpdate$/ ); }); @@ -321,7 +322,65 @@ export function StatusBarSuite(this: Suite): void { assert.ok(syncBar.tooltip); assert.match( syncBar.tooltip, - /Next sync \d\d:\d\d:\d\d\nunknown changes\nUpdate/ + /^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` ); }); } From 2988565ffc8a64b68c9394ca9997b63fee5b907f Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 21:40:04 +0300 Subject: [PATCH 21/23] refactor: rename statusbar.ts -> statusBar.ts --- src/repository.ts | 2 +- src/{statusbar.ts => statusBar.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{statusbar.ts => statusBar.ts} (100%) diff --git a/src/repository.ts b/src/repository.ts index dcbd83b..6820df1 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -52,7 +52,7 @@ import { delay, } from './util'; import { memoize, throttle, debounce } from './decorators'; -import { StatusBarCommands } from './statusbar'; +import { StatusBarCommands } from './statusBar'; import typedConfig, { AutoSyncIntervalMs } from './config'; import * as path from 'path'; diff --git a/src/statusbar.ts b/src/statusBar.ts similarity index 100% rename from src/statusbar.ts rename to src/statusBar.ts From 96cf0e2d80f9fef0804976f1515b2abb5667e899 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 21:55:37 +0300 Subject: [PATCH 22/23] refactor: extract status bar tests into statusBarSuite.ts --- src/test/suite/extension.test.ts | 2 + src/test/suite/stateSuite.ts | 238 ----------------------------- src/test/suite/statusBarSuite.ts | 248 +++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 238 deletions(-) create mode 100644 src/test/suite/statusBarSuite.ts 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/stateSuite.ts b/src/test/suite/stateSuite.ts index a94dc9d..65b2911 100644 --- a/src/test/suite/stateSuite.ts +++ b/src/test/suite/stateSuite.ts @@ -4,15 +4,9 @@ import { assertGroups, cleanupFossil, fakeExecutionResult, - fakeFossilBranch, - fakeFossilChanges, fakeFossilStatus, - fakeRawExecutionResult, getExecStub, - getModel, - getRawExecStub, getRepository, - statusBarCommands, } from './common'; import * as assert from 'assert/strict'; import * as fs from 'fs/promises'; @@ -154,240 +148,8 @@ function PullAndPushSuite(this: Suite): void { }); } -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` - ); - }); -} - export function UpdateSuite(this: Suite): void { suite('Pull and Push', PullAndPushSuite); - suite('Status Bar', StatusBarSuite); test('Change branch to trunk', async () => { const execStub = getExecStub(this.ctx.sandbox); 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` + ); + }); +} From 44f927312672effa453f4a1db0dee90bb3e3b4d9 Mon Sep 17 00:00:00 2001 From: Arseniy Terekhin Date: Sun, 1 Dec 2024 21:57:56 +0300 Subject: [PATCH 23/23] dev: ignore "delay" coverage in tests (+4 lines coverage) --- src/test/suite/common.ts | 1 + src/test/suite/renameSuite.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/test/suite/common.ts b/src/test/suite/common.ts index de57b70..9937ef6 100644 --- a/src/test/suite/common.ts +++ b/src/test/suite/common.ts @@ -290,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*$/); diff --git a/src/test/suite/renameSuite.ts b/src/test/suite/renameSuite.ts index f4b92de..bc90948 100644 --- a/src/test/suite/renameSuite.ts +++ b/src/test/suite/renameSuite.ts @@ -91,6 +91,7 @@ export function RenameSuite(this: Suite): void { if (sim.callCount != 0) { break; } + /* c8 ignore next 2 */ await delay(5); } sinon.assert.calledOnceWithExactly(