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