diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 36501cbe2ac..cf6aef5580b 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -322,6 +322,9 @@ export interface IAppState { /** Whether or not the worktrees dropdown should be shown in the toolbar */ readonly showWorktrees: boolean + /** Whether linked worktrees should be shown under their repository in the sidebar */ + readonly showWorktreesInSidebar: boolean + /** Whether or not the Compare tab should be shown in the repository view */ readonly showCompareTab: boolean diff --git a/app/src/lib/git/worktree.ts b/app/src/lib/git/worktree.ts index 158acbd4f96..2722412fd56 100644 --- a/app/src/lib/git/worktree.ts +++ b/app/src/lib/git/worktree.ts @@ -5,6 +5,15 @@ import type { WorktreeEntry, WorktreeType } from '../../models/worktree' import { git } from './core' import { normalizePath } from '../helpers/path' +function getDotGitPath(repositoryPath: string): string { + return Path.join(repositoryPath, '.git') +} + +export interface IWorktreePathInfo { + readonly isLinkedWorktree: boolean + readonly mainWorktreePath: string | null +} + export function parseWorktreePorcelainOutput( stdout: string ): ReadonlyArray { @@ -104,6 +113,10 @@ export async function removeWorktree( await git(args, repository.path, 'removeWorktree') } +export async function pruneWorktrees(repository: Repository): Promise { + await git(['worktree', 'prune'], repository.path, 'pruneWorktrees') +} + export async function moveWorktree( repository: Repository, oldPath: string, @@ -135,17 +148,48 @@ export async function getMainWorktreePath( return main?.path ?? null } -/** - * Synchronously checks if a repository path is a linked worktree by examining - * whether `.git` is a file (linked worktree) or directory (main worktree). - */ -export function isLinkedWorktreeSync(repositoryPath: string): boolean { +export function getWorktreePathInfoSync( + repositoryPath: string +): IWorktreePathInfo | null { try { - const dotGit = Path.join(repositoryPath, '.git') + const dotGit = getDotGitPath(repositoryPath) // eslint-disable-next-line no-sync const stats = Fs.statSync(dotGit) - return stats.isFile() + + if (stats.isDirectory()) { + return { isLinkedWorktree: false, mainWorktreePath: repositoryPath } + } + + if (!stats.isFile()) { + return null + } + + // eslint-disable-next-line no-sync + const contents = Fs.readFileSync(dotGit, 'utf8').trim() + if (!contents.startsWith('gitdir: ')) { + return null + } + + const gitDirPath = Path.resolve( + repositoryPath, + contents.substring('gitdir: '.length) + ) + + // eslint-disable-next-line no-sync + const commondir = Fs.readFileSync( + Path.join(gitDirPath, 'commondir'), + 'utf8' + ).trim() + if (commondir.length === 0) { + return null + } + + const commonGitDir = Path.resolve(gitDirPath, commondir) + return { + isLinkedWorktree: true, + mainWorktreePath: Path.dirname(commonGitDir), + } } catch { - return false + return null } } diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index ab948e1f95d..9e52c62a13b 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -383,6 +383,13 @@ import { BranchPruner } from './helpers/branch-pruner' import { createTutorialRepository } from './helpers/create-tutorial-repository' import { findRemoteBranchName } from './helpers/find-branch-name' import { RepositoryIndicatorUpdater } from './helpers/repository-indicator-updater' +import { + createSidebarStateFromStatus, + findSidebarWorktreeStateRepository, + getCurrentWorktreeEntryForRepository, + shouldRefreshSidebarWorktrees, + withSidebarWorktrees, +} from './helpers/sidebar-worktrees' import { OnboardingTutorialAssessor } from './helpers/tutorial-assessor' import { getNotificationsEnabled, @@ -396,6 +403,7 @@ import { } from './updates/changes-state' import { updateRemoteUrl } from './updates/update-remote-url' import { getRepoHooks } from '../hooks/get-repo-hooks' +import pLimit from 'p-limit' const LastSelectedRepositoryIDKey = 'last-selected-repository-id' @@ -423,6 +431,7 @@ const branchDropdownWidthConfigKey: string = 'branch-dropdown-width' const defaultWorktreeDropdownWidth: number = 230 const worktreeDropdownWidthConfigKey: string = 'worktree-dropdown-width' +const MaxConcurrentSidebarWorktreePreloads = 4 const defaultPushPullButtonWidth: number = 230 const pushPullButtonWidthConfigKey: string = 'push-pull-button-width' @@ -480,6 +489,7 @@ const shellKey = 'shell' const showRecentRepositoriesKey = 'show-recent-repositories' const showWorktreesKey = 'show-worktrees' +const showWorktreesInSidebarKey = 'show-worktrees-in-sidebar' const showCompareTabKey = 'show-compare-tab' const showCompareTabDefault = true const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' @@ -651,6 +661,8 @@ export class AppStore extends TypedBaseStore { private titleBarStyle: TitleBarStyle = 'native' private showRecentRepositories: boolean = true private showWorktrees: boolean = false + private showWorktreesInSidebar: boolean = false + private readonly lastSidebarWorktreeRefreshAt = new Map() private showCompareTab: boolean = showCompareTabDefault private hideWindowOnQuit: boolean = __DARWIN__ @@ -772,6 +784,8 @@ export class AppStore extends TypedBaseStore { this.showRecentRepositories = getBoolean(showRecentRepositoriesKey) ?? true this.showWorktrees = getBoolean(showWorktreesKey) ?? false + this.showWorktreesInSidebar = + this.showWorktrees && (getBoolean(showWorktreesInSidebarKey) ?? false) this.showCompareTab = getBoolean(showCompareTabKey, showCompareTabDefault) this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater( @@ -929,6 +943,16 @@ export class AppStore extends TypedBaseStore { } } + private pruneSidebarWorktreeRefreshCache() { + const currentRepositoryHashes = new Set(this.repositories.map(r => r.hash)) + + for (const hash of this.lastSidebarWorktreeRefreshAt.keys()) { + if (!currentRepositoryHashes.has(hash)) { + this.lastSidebarWorktreeRefreshAt.delete(hash) + } + } + } + private recordTutorialStepCompleted(step: TutorialStep): void { if (!isValidTutorialStep(step)) { return @@ -1043,7 +1067,11 @@ export class AppStore extends TypedBaseStore { this.repositoriesStore.onDidUpdate(updateRepositories => { this.repositories = updateRepositories + this.pruneSidebarWorktreeRefreshCache() this.updateRepositorySelectionAfterRepositoriesChanged() + if (this.showWorktreesInSidebar) { + void this.preloadSidebarWorktrees() + } this.emitUpdate() }) @@ -1227,6 +1255,7 @@ export class AppStore extends TypedBaseStore { titleBarStyle: this.titleBarStyle, showRecentRepositories: this.showRecentRepositories, showWorktrees: this.showWorktrees, + showWorktreesInSidebar: this.showWorktreesInSidebar, showCompareTab: this.showCompareTab, apiRepositories: this.apiRepositoriesStore.getState(), useWindowsOpenSSH: this.useWindowsOpenSSH, @@ -2147,7 +2176,8 @@ export class AppStore extends TypedBaseStore { /** This shouldn't be called directly. See `Dispatcher`. */ public async _selectRepository( repository: Repository | CloningRepository | null, - persistSelection: boolean = true + persistSelection: boolean = true, + followPreferredWorktree: boolean = true ): Promise { const previouslySelectedRepository = this.selectedRepository @@ -2183,7 +2213,7 @@ export class AppStore extends TypedBaseStore { // When returning to a repository that has worktrees, restore the // previously active linked worktree so the user doesn't always land // on the main worktree after switching repos. - if (!repository.isLinkedWorktree) { + if (followPreferredWorktree && !repository.isLinkedWorktree) { const repoPath = normalizePath(repository.path) const preferredPath = getPreferredWorktreePath(repoPath) @@ -2511,8 +2541,12 @@ export class AppStore extends TypedBaseStore { this.accounts = accounts this.repositories = repositories + this.pruneSidebarWorktreeRefreshCache() this.updateRepositorySelectionAfterRepositoriesChanged() + if (this.showWorktreesInSidebar) { + void this.preloadSidebarWorktrees() + } this.sidebarWidth = constrain( getNumber(sidebarWidthConfigKey, defaultSidebarWidth) @@ -2999,7 +3033,20 @@ export class AppStore extends TypedBaseStore { r.id === selectedRepository.id ) || null - newSelectedRepository = r + if (r !== null) { + newSelectedRepository = r + } else if ( + selectedRepository instanceof Repository && + selectedRepository.id < 0 + ) { + // Synthetic sidebar-only worktree rows are transient selections and + // won't exist in the saved repositories list. Preserve the current + // selection across repository store updates instead of falling back to + // the previously selected saved repository. + newSelectedRepository = selectedRepository + } else { + newSelectedRepository = null + } } if (newSelectedRepository === null && this.repositories.length > 0) { @@ -4013,6 +4060,10 @@ export class AppStore extends TypedBaseStore { const state = this.repositoryStateCache.get(repository) const gitStore = this.gitStoreCache.get(repository) + const sidebarRepository = findSidebarWorktreeStateRepository( + this.repositories, + repository + ) // if we cannot get a valid status it's a good indicator that the repository // is in a bad state - let's mark it as missing here and give up on the @@ -4029,6 +4080,35 @@ export class AppStore extends TypedBaseStore { await gitStore.loadRemotes() await gitStore.loadBranches() await gitStore.loadWorktrees() + this.repositoryStateCache.updateWorktreesState(repository, () => ({ + allWorktrees: gitStore.allWorktrees, + currentWorktree: gitStore.currentWorktree, + })) + if (sidebarRepository !== repository) { + this.repositoryStateCache.updateWorktreesState(sidebarRepository, () => ({ + allWorktrees: gitStore.allWorktrees, + currentWorktree: getCurrentWorktreeEntryForRepository( + gitStore.allWorktrees, + sidebarRepository + ), + })) + } + + if (this.showWorktreesInSidebar) { + this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now()) + this.lastSidebarWorktreeRefreshAt.set(sidebarRepository.hash, Date.now()) + this.updateSidebarIndicator(repository, status) + const refreshed = this.localRepositoryStateLookup.get( + sidebarRepository.id + ) + if (refreshed !== undefined) { + this.localRepositoryStateLookup.set( + sidebarRepository.id, + withSidebarWorktrees(refreshed, gitStore.allWorktrees) + ) + } + } + this.emitUpdate() const section = state.selectedSection let refreshSectionPromise: Promise @@ -4080,6 +4160,63 @@ export class AppStore extends TypedBaseStore { } } + private async preloadSidebarWorktrees() { + const limit = pLimit(MaxConcurrentSidebarWorktreePreloads) + + await Promise.all( + this.repositories.map(repository => + limit(async () => { + const exists = await pathExists(repository.path) + if (!exists) { + const existing = this.localRepositoryStateLookup.get(repository.id) + if (existing !== undefined) { + this.localRepositoryStateLookup.set( + repository.id, + withSidebarWorktrees(existing, []) + ) + } + return + } + + try { + const gitStore = this.gitStoreCache.get(repository) + await gitStore.loadWorktrees() + this.repositoryStateCache.updateWorktreesState(repository, () => ({ + allWorktrees: gitStore.allWorktrees, + currentWorktree: gitStore.currentWorktree, + })) + + const existing = this.localRepositoryStateLookup.get(repository.id) + if (existing !== undefined) { + this.localRepositoryStateLookup.set( + repository.id, + withSidebarWorktrees(existing, gitStore.allWorktrees) + ) + } + this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now()) + this.emitUpdate() + } catch (error) { + log.warn( + `[AppStore] Failed to preload sidebar worktrees for '${nameOf( + repository + )}'`, + error + ) + const existing = this.localRepositoryStateLookup.get(repository.id) + if (existing !== undefined) { + this.localRepositoryStateLookup.set( + repository.id, + withSidebarWorktrees(existing, []) + ) + } + } + }) + ) + ) + + this.emitUpdate() + } + private async updateStashEntryCountMetric( repository: Repository, desktopStashEntryCount: number, @@ -4103,10 +4240,10 @@ export class AppStore extends TypedBaseStore { /** * Update the repository sidebar indicator for the repository */ - private async updateSidebarIndicator( + private updateSidebarIndicator( repository: Repository, status: IStatusResult | null - ): Promise { + ): void { const lookup = this.localRepositoryStateLookup if (repository.missing) { @@ -4119,18 +4256,26 @@ export class AppStore extends TypedBaseStore { return } - lookup.set(repository.id, { - aheadBehind: status.branchAheadBehind || null, - changedFilesCount: status.workingDirectory.files.length, - branchName: status.currentBranch || null, - defaultBranchName: repository.defaultBranch, - }) + lookup.set( + repository.id, + createSidebarStateFromStatus( + repository, + status, + lookup.get(repository.id), + this.repositoryStateCache.get(repository).worktreesState.allWorktrees, + this.showWorktreesInSidebar + ) + ) } /** * Refresh indicator in repository list for a specific repository */ private refreshIndicatorForRepository = async (repository: Repository) => { const lookup = this.localRepositoryStateLookup + const sidebarRepository = findSidebarWorktreeStateRepository( + this.repositories, + repository + ) if (repository.missing) { lookup.delete(repository.id) @@ -4150,6 +4295,28 @@ export class AppStore extends TypedBaseStore { return } + if ( + this.showWorktreesInSidebar && + shouldRefreshSidebarWorktrees( + this.lastSidebarWorktreeRefreshAt.get(sidebarRepository.hash) + ) + ) { + const sidebarGitStore = this.gitStoreCache.get(sidebarRepository) + await sidebarGitStore.loadWorktrees() + this.repositoryStateCache.updateWorktreesState(sidebarRepository, () => ({ + allWorktrees: sidebarGitStore.allWorktrees, + currentWorktree: sidebarGitStore.currentWorktree, + })) + const refreshed = lookup.get(sidebarRepository.id) + if (refreshed !== undefined) { + lookup.set( + sidebarRepository.id, + withSidebarWorktrees(refreshed, sidebarGitStore.allWorktrees) + ) + } + this.lastSidebarWorktreeRefreshAt.set(sidebarRepository.hash, Date.now()) + } + this.updateSidebarIndicator(repository, status) this.emitUpdate() @@ -4171,6 +4338,7 @@ export class AppStore extends TypedBaseStore { changedFilesCount: existing?.changedFilesCount ?? 0, branchName: existing?.branchName ?? null, defaultBranchName: existing?.defaultBranchName ?? null, + allWorktrees: existing?.allWorktrees ?? [], }) this.emitUpdate() } @@ -4245,10 +4413,33 @@ export class AppStore extends TypedBaseStore { } setBoolean(showWorktreesKey, showWorktrees) this.showWorktrees = showWorktrees + if (!showWorktrees && this.showWorktreesInSidebar) { + setBoolean(showWorktreesInSidebarKey, false) + this.showWorktreesInSidebar = false + this.lastSidebarWorktreeRefreshAt.clear() + } this.updateResizableConstraints() this.emitUpdate() } + public _setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) { + if (this.showWorktreesInSidebar === showWorktreesInSidebar) { + return + } + + if (showWorktreesInSidebar && !this.showWorktrees) { + return + } + + setBoolean(showWorktreesInSidebarKey, showWorktreesInSidebar) + this.showWorktreesInSidebar = showWorktreesInSidebar + this.lastSidebarWorktreeRefreshAt.clear() + if (showWorktreesInSidebar) { + void this.preloadSidebarWorktrees() + } + this.emitUpdate() + } + public _setShowCompareTab(showCompareTab: boolean) { if (this.showCompareTab === showCompareTab) { return @@ -7241,11 +7432,29 @@ export class AppStore extends TypedBaseStore { continue } - const addedRepo = await this.repositoriesStore.addRepository( + let addedRepo = await this.repositoriesStore.addRepository( validatedPath, login ) + // When a linked worktree is added as a standalone repository and the + // main worktree is already known to Desktop, inherit that GitHub + // association up front so the saved row lands in the same top-level + // group after restart. + const mainWorktreeRepo = addedRepo.isLinkedWorktree + ? matchExistingRepository(repositories, addedRepo.mainWorktreePath) + : undefined + + if ( + mainWorktreeRepo !== undefined && + isRepositoryWithGitHubRepository(mainWorktreeRepo) + ) { + addedRepo = await this.repositoriesStore.setGitHubRepository( + addedRepo, + mainWorktreeRepo.gitHubRepository + ) + } + // initialize the remotes for this new repository to ensure it can fetch // it's GitHub-related details using the GitHub API (if applicable) const gitStore = this.gitStoreCache.get(addedRepo) diff --git a/app/src/lib/stores/helpers/sidebar-worktrees.ts b/app/src/lib/stores/helpers/sidebar-worktrees.ts new file mode 100644 index 00000000000..49f68e722da --- /dev/null +++ b/app/src/lib/stores/helpers/sidebar-worktrees.ts @@ -0,0 +1,75 @@ +import { IStatusResult } from '../../git' +import { normalizePath } from '../../helpers/path' +import { ILocalRepositoryState, Repository } from '../../../models/repository' +import { WorktreeEntry } from '../../../models/worktree' + +/** + * Refresh sidebar worktree metadata more sparingly than the repository + * indicator loop to avoid repeatedly shelling out to `git worktree list`. + */ +export const SidebarWorktreeRefreshInterval = 2 * 60 * 1000 + +export function findSidebarWorktreeStateRepository( + repositories: ReadonlyArray, + repository: Repository +) { + if (!repository.isLinkedWorktree) { + return repository + } + + const mainWorktreePath = normalizePath(repository.mainWorktreePath) + return ( + repositories.find( + candidate => normalizePath(candidate.path) === mainWorktreePath + ) ?? repository + ) +} + +export function getCurrentWorktreeEntryForRepository( + allWorktrees: ReadonlyArray, + repository: Repository +) { + return ( + allWorktrees.find( + worktree => + normalizePath(worktree.path) === normalizePath(repository.path) + ) ?? null + ) +} + +export function createSidebarStateFromStatus( + repository: Repository, + status: IStatusResult, + existing: ILocalRepositoryState | undefined, + allWorktrees: ReadonlyArray, + showWorktreesInSidebar: boolean +): ILocalRepositoryState { + return { + aheadBehind: status.branchAheadBehind || null, + changedFilesCount: status.workingDirectory.files.length, + branchName: status.currentBranch || null, + defaultBranchName: repository.defaultBranch, + allWorktrees: showWorktreesInSidebar ? allWorktrees : [], + } +} + +export function withSidebarWorktrees( + existing: ILocalRepositoryState, + allWorktrees: ReadonlyArray +): ILocalRepositoryState { + return { + ...existing, + allWorktrees, + } +} + +export function shouldRefreshSidebarWorktrees( + lastRefreshedAt: number | undefined, + now: number = Date.now() +) { + if (lastRefreshedAt === undefined) { + return true + } + + return now - lastRefreshedAt >= SidebarWorktreeRefreshInterval +} diff --git a/app/src/lib/stores/repositories-store.ts b/app/src/lib/stores/repositories-store.ts index eb4a98e49f5..9d4d598b05b 100644 --- a/app/src/lib/stores/repositories-store.ts +++ b/app/src/lib/stores/repositories-store.ts @@ -479,6 +479,12 @@ export class RepositoriesStore extends TypedBaseStore< repository: Repository, date: number = Date.now() ): Promise { + // Synthetic sidebar-only worktree rows are transient repositories that + // are not persisted in the repositories store. + if (repository.id < 0) { + return + } + await this.db.repositories.update(repository.id, { lastStashCheckDate: date, }) @@ -496,6 +502,12 @@ export class RepositoriesStore extends TypedBaseStore< public async getLastStashCheckDate( repository: Repository ): Promise { + // Synthetic sidebar-only worktree rows are transient repositories that + // are not persisted in the repositories store. + if (repository.id < 0) { + return null + } + let lastCheckDate = this.lastStashCheckCache.get(repository.id) || null if (lastCheckDate !== null) { return lastCheckDate diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 549d6bfb737..50a25c18f54 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -513,5 +513,7 @@ export type PopupDetail = type: PopupType.DeleteWorktree repository: Repository worktreePath: string + storedRepositoryToRemove?: Repository + isDeletingCurrentWorktree?: boolean } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/models/repository.ts b/app/src/models/repository.ts index e855665eb8a..2430b12b7f7 100644 --- a/app/src/models/repository.ts +++ b/app/src/models/repository.ts @@ -2,13 +2,14 @@ import * as Path from 'path' import { GitHubRepository, ForkedGitHubRepository } from './github-repository' import { IAheadBehind } from './branch' +import { WorktreeEntry } from './worktree' import { WorkflowPreferences, ForkContributionTarget, } from './workflow-preferences' import { assertNever, fatalError } from '../lib/fatal-error' import { createEqualityHash } from './equality-hash' -import { isLinkedWorktreeSync } from '../lib/git/worktree' +import { getWorktreePathInfoSync } from '../lib/git/worktree' import { getRemotes } from '../lib/git' import { findDefaultRemote } from '../lib/stores/helpers/find-default-remote' import { isTrustedRemoteHost } from '../lib/api' @@ -56,7 +57,9 @@ export class Repository { */ private _url: string | null = null + private _hasLoadedWorktreeInfo = false private _isLinkedWorktree: boolean | undefined = undefined + private _mainWorktreePath: string | undefined = undefined /** * @param path The working directory of this repository @@ -98,15 +101,29 @@ export class Repository { ) } + private ensureWorktreeInfoLoaded() { + if (this._hasLoadedWorktreeInfo) { + return + } + + const worktreeInfo = getWorktreePathInfoSync(this.path) + this._isLinkedWorktree = worktreeInfo?.isLinkedWorktree ?? false + this._mainWorktreePath = worktreeInfo?.mainWorktreePath ?? this.path + this._hasLoadedWorktreeInfo = true + } + public get path(): string { return this.mainWorkTree.path } public get isLinkedWorktree(): boolean { - if (this._isLinkedWorktree === undefined) { - this._isLinkedWorktree = isLinkedWorktreeSync(this.path) - } - return this._isLinkedWorktree + this.ensureWorktreeInfoLoaded() + return this._isLinkedWorktree ?? false + } + + public get mainWorktreePath(): string { + this.ensureWorktreeInfoLoaded() + return this._mainWorktreePath ?? this.path } public get url(): string | null { @@ -229,6 +246,10 @@ export interface ILocalRepositoryState { * The name of the default branch, or `undefined` if not available. */ readonly defaultBranchName: string | null + /** + * All worktrees known for this repository. + */ + readonly allWorktrees: ReadonlyArray } /** diff --git a/app/src/ui/add-repository/add-existing-repository.tsx b/app/src/ui/add-repository/add-existing-repository.tsx index f17e24fb217..a70ef1540b1 100644 --- a/app/src/ui/add-repository/add-existing-repository.tsx +++ b/app/src/ui/add-repository/add-existing-repository.tsx @@ -77,7 +77,9 @@ export class AddExistingRepository extends React.Component< } private async updatePath(path: string) { - this.setState({ path }) + await new Promise(resolve => { + this.setState({ path }, resolve) + }) } private async validatePath(path: string): Promise { @@ -89,7 +91,8 @@ export class AddExistingRepository extends React.Component< return false } - const type = await getRepositoryType(path) + const resolvedPath = this.resolvedPath(path) + const type = await getRepositoryType(resolvedPath) const isRepository = type.kind !== 'missing' && type.kind !== 'unsafe' const isRepositoryUnsafe = type.kind === 'unsafe' diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 32236efe1af..d134e83e3f9 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1689,6 +1689,7 @@ export class App extends React.Component { titleBarStyle={this.state.titleBarStyle} showRecentRepositories={this.state.showRecentRepositories} showWorktrees={this.state.showWorktrees} + showWorktreesInSidebar={this.state.showWorktreesInSidebar} showCompareTab={this.state.showCompareTab} repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled} hideWindowOnQuit={this.state.hideWindowOnQuit} @@ -2762,6 +2763,8 @@ export class App extends React.Component { key="delete-worktree" repository={popup.repository} worktreePath={popup.worktreePath} + storedRepositoryToRemove={popup.storedRepositoryToRemove} + isDeletingCurrentWorktree={popup.isDeletingCurrentWorktree} dispatcher={this.props.dispatcher} onDismissed={onPopupDismissedFn} /> @@ -3071,9 +3074,14 @@ export class App extends React.Component { const { useCustomShell, selectedShell } = this.state const filterText = this.state.repositoryFilterText - const repositories = this.state.repositories.filter( - r => !(r instanceof Repository && r.isLinkedWorktree) - ) + const repositories = this.state.showWorktreesInSidebar + ? [...this.state.repositories] + : this.state.repositories.filter( + r => !(r instanceof Repository && r.isLinkedWorktree) + ) + const localRepositoryStateLookup = this.state.showWorktreesInSidebar + ? new Map(this.state.localRepositoryStateLookup) + : this.state.localRepositoryStateLookup return ( { repositories={repositories} recentRepositories={this.state.recentRepositories} showRecentRepositories={this.state.showRecentRepositories} - localRepositoryStateLookup={this.state.localRepositoryStateLookup} + localRepositoryStateLookup={localRepositoryStateLookup} askForConfirmationOnRemoveRepository={ this.state.askForConfirmationOnRepositoryRemoval } @@ -3097,6 +3105,7 @@ export class App extends React.Component { shellLabel={useCustomShell ? undefined : selectedShell} dispatcher={this.props.dispatcher} showBranchNameInRepoList={this.state.showBranchNameInRepoList} + showWorktreesInSidebar={this.state.showWorktreesInSidebar} /> ) } @@ -3820,7 +3829,7 @@ export class App extends React.Component { } private onSelectionChanged = (repository: Repository | CloningRepository) => { - this.props.dispatcher.selectRepository(repository) + this.props.dispatcher.selectRepository(repository, true, false) this.props.dispatcher.closeFoldout(FoldoutType.Repository) } diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index c877b5cd98b..010e187f218 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -314,9 +314,14 @@ export class Dispatcher { /** Select the repository. */ public selectRepository( repository: Repository | CloningRepository, - persistSelection: boolean = true + persistSelection: boolean = true, + followPreferredWorktree: boolean = true ): Promise { - return this.appStore._selectRepository(repository, persistSelection) + return this.appStore._selectRepository( + repository, + persistSelection, + followPreferredWorktree + ) } /** Change the selected section in the repository. */ @@ -2932,6 +2937,10 @@ export class Dispatcher { this.appStore._setShowWorktrees(showWorktrees) } + public setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) { + this.appStore._setShowWorktreesInSidebar(showWorktreesInSidebar) + } + public setShowCompareTab(showCompareTab: boolean) { this.appStore._setShowCompareTab(showCompareTab) } diff --git a/app/src/ui/preferences/appearance.tsx b/app/src/ui/preferences/appearance.tsx index 77cd0d1089d..5170b9ad030 100644 --- a/app/src/ui/preferences/appearance.tsx +++ b/app/src/ui/preferences/appearance.tsx @@ -29,6 +29,8 @@ interface IAppearanceProps { readonly onShowRecentRepositoriesChanged: (show: boolean) => void readonly showWorktrees: boolean readonly onShowWorktreesChanged: (show: boolean) => void + readonly showWorktreesInSidebar: boolean + readonly onShowWorktreesInSidebarChanged: (show: boolean) => void readonly showCompareTab: boolean readonly onShowCompareTabChanged: (show: boolean) => void readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting @@ -47,6 +49,7 @@ interface IAppearanceState { readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean readonly showWorktrees: boolean + readonly showWorktreesInSidebar: boolean readonly showCompareTab: boolean } @@ -76,6 +79,7 @@ export class Appearance extends React.Component< titleBarStyle: props.titleBarStyle, showRecentRepositories: props.showRecentRepositories, showWorktrees: props.showWorktrees, + showWorktreesInSidebar: props.showWorktreesInSidebar, showCompareTab: props.showCompareTab, } @@ -85,7 +89,13 @@ export class Appearance extends React.Component< } public async componentDidUpdate(prevProps: IAppearanceProps) { - if (prevProps === this.props) { + if ( + prevProps.selectedTheme === this.props.selectedTheme && + prevProps.selectedTabSize === this.props.selectedTabSize && + prevProps.showWorktrees === this.props.showWorktrees && + prevProps.showWorktreesInSidebar === this.props.showWorktreesInSidebar && + prevProps.showCompareTab === this.props.showCompareTab + ) { return } @@ -99,7 +109,13 @@ export class Appearance extends React.Component< const selectedTabSize = this.props.selectedTabSize - this.setState({ selectedTheme, selectedTabSize }) + this.setState({ + selectedTheme, + selectedTabSize, + showWorktrees: this.props.showWorktrees, + showWorktreesInSidebar: this.props.showWorktreesInSidebar, + showCompareTab: this.props.showCompareTab, + }) } private initializeSelectedTheme = async () => { @@ -124,7 +140,10 @@ export class Appearance extends React.Component< event: React.FormEvent ) => { const show = event.currentTarget.checked - this.setState({ showWorktrees: show }) + this.setState({ + showWorktrees: show, + showWorktreesInSidebar: show ? this.state.showWorktreesInSidebar : false, + }) this.props.onShowWorktreesChanged(show) } @@ -136,6 +155,14 @@ export class Appearance extends React.Component< this.props.onShowCompareTabChanged(show) } + private onShowWorktreesInSidebarChanged = ( + event: React.FormEvent + ) => { + const show = event.currentTarget.checked + this.setState({ showWorktreesInSidebar: show }) + this.props.onShowWorktreesInSidebarChanged(show) + } + private onSelectedTabSizeChanged = ( event: React.FormEvent ) => { @@ -363,6 +390,17 @@ export class Appearance extends React.Component< } onChange={this.onShowWorktreesChanged} /> + {this.state.showWorktrees && ( + + )}

{'Commit list'}

diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 92554346062..9400b6cf986 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -101,6 +101,7 @@ interface IPreferencesProps { readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean readonly showWorktrees: boolean + readonly showWorktreesInSidebar: boolean readonly showCompareTab: boolean readonly repositoryIndicatorsEnabled: boolean readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting @@ -151,6 +152,7 @@ interface IPreferencesState { readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean readonly showWorktrees: boolean + readonly showWorktreesInSidebar: boolean readonly showCompareTab: boolean /** * If unable to save Git configuration values (name, email) @@ -241,6 +243,7 @@ export class Preferences extends React.Component< titleBarStyle: this.props.titleBarStyle, showRecentRepositories: this.props.showRecentRepositories, showWorktrees: this.props.showWorktrees, + showWorktreesInSidebar: this.props.showWorktreesInSidebar, showCompareTab: this.props.showCompareTab, repositoryIndicatorsEnabled: this.props.repositoryIndicatorsEnabled, showBranchNameInRepoList: this.props.showBranchNameInRepoList, @@ -593,6 +596,10 @@ export class Preferences extends React.Component< } showWorktrees={this.state.showWorktrees} onShowWorktreesChanged={this.onShowWorktreesChanged} + showWorktreesInSidebar={this.state.showWorktreesInSidebar} + onShowWorktreesInSidebarChanged={ + this.onShowWorktreesInSidebarChanged + } showCompareTab={this.state.showCompareTab} onShowCompareTabChanged={this.onShowCompareTabChanged} showBranchNameInRepoList={this.state.showBranchNameInRepoList} @@ -893,7 +900,18 @@ export class Preferences extends React.Component< } private onShowWorktreesChanged = (showWorktrees: boolean) => { - this.setState({ showWorktrees }) + this.setState(state => ({ + showWorktrees, + showWorktreesInSidebar: showWorktrees + ? state.showWorktreesInSidebar + : false, + })) + } + + private onShowWorktreesInSidebarChanged = ( + showWorktreesInSidebar: boolean + ) => { + this.setState({ showWorktreesInSidebar }) } private onShowCompareTabChanged = (showCompareTab: boolean) => { @@ -981,6 +999,12 @@ export class Preferences extends React.Component< dispatcher.setShowWorktrees(this.state.showWorktrees) } + if ( + this.state.showWorktreesInSidebar !== this.props.showWorktreesInSidebar + ) { + dispatcher.setShowWorktreesInSidebar(this.state.showWorktreesInSidebar) + } + if (this.state.showCompareTab !== this.props.showCompareTab) { dispatcher.setShowCompareTab(this.state.showCompareTab) } diff --git a/app/src/ui/repositories-list/group-repositories.ts b/app/src/ui/repositories-list/group-repositories.ts index d2d4d4d83a8..9bf342fd918 100644 --- a/app/src/ui/repositories-list/group-repositories.ts +++ b/app/src/ui/repositories-list/group-repositories.ts @@ -1,18 +1,22 @@ import { Repository, ILocalRepositoryState, - nameOf, isRepositoryWithGitHubRepository, RepositoryWithGitHubRepository, } from '../../models/repository' import { CloningRepository } from '../../models/cloning-repository' import { getHTMLURL } from '../../lib/api' -import { caseInsensitiveCompare, compare } from '../../lib/compare' +import { compare } from '../../lib/compare' import { IFilterListGroup, IFilterListItem } from '../lib/filter-list' import { IAheadBehind } from '../../models/branch' import { assertNever } from '../../lib/fatal-error' import { isGHE, isGHES } from '../../lib/endpoint-capabilities' import { Owner } from '../../models/owner' +import { normalizePath } from '../../lib/helpers/path' +import { + getRepositoryListTitle, + toSortedRepositoryListItems, +} from './worktree-list-items' export type RepositoryListGroup = ( | { @@ -57,12 +61,23 @@ export type Repositoryish = Repository | CloningRepository export interface IRepositoryListItem extends IFilterListItem { readonly text: ReadonlyArray readonly id: string + readonly title: string readonly repository: Repositoryish readonly needsDisambiguation: boolean readonly aheadBehind: IAheadBehind | null readonly changedFilesCount: number readonly branchName: string | null readonly defaultBranchName: string | null + readonly isNestedWorktree: boolean + readonly mainWorktreeName: string | null + readonly isVirtualLinkedWorktree: boolean + readonly isPrunableWorktree: boolean + readonly worktreePath: string | null + readonly sourceRepository: Repository | null +} + +interface IGroupRepositoriesOptions { + readonly showWorktreesInSidebar?: boolean } const recentRepositoriesThreshold = 7 @@ -97,11 +112,24 @@ type RepoGroupItem = { group: RepositoryListGroup; repos: Repositoryish[] } export function groupRepositories( repositories: ReadonlyArray, localRepositoryStateLookup: ReadonlyMap, - recentRepositories: ReadonlyArray + recentRepositories: ReadonlyArray, + options: IGroupRepositoriesOptions = {} ): ReadonlyArray> { const includeRecentGroup = repositories.length > recentRepositoriesThreshold const recentSet = includeRecentGroup ? new Set(recentRepositories) : undefined const groups = new Map() + const repositoryByPath = new Map() + const storedRepositoryPaths = new Set() + + for (const repository of repositories) { + if (!(repository instanceof Repository)) { + continue + } + + const normalizedPath = normalizePath(repository.path) + repositoryByPath.set(normalizedPath, repository) + storedRepositoryPaths.add(normalizedPath) + } const addToGroup = (group: RepositoryListGroup, repo: Repositoryish) => { const key = getGroupKey(group) @@ -130,22 +158,24 @@ export function groupRepositories( group, repos, localRepositoryStateLookup, - groups + groups, + repositoryByPath, + storedRepositoryPaths, + options ), })) } -// Returns the display title for a repository, which is either the alias -// (if available) or the name. -const getDisplayTitle = (r: Repositoryish) => - r instanceof Repository && r.alias != null ? r.alias : r.name - const toSortedListItems = ( group: RepositoryListGroup, repositories: ReadonlyArray, localRepositoryStateLookup: ReadonlyMap, - groups: Map + groups: Map, + repositoryByPath: ReadonlyMap, + storedRepositoryPaths: ReadonlySet, + options: IGroupRepositoriesOptions ): IRepositoryListItem[] => { + const showWorktreesInSidebar = options.showWorktreesInSidebar ?? false const groupNames = new Map() const allNames = new Map() @@ -156,39 +186,23 @@ const toSortedListItems = ( continue } - for (const title of groupItem.repos.map(getDisplayTitle)) { + for (const title of groupItem.repos.map(repo => + getRepositoryListTitle(repo, showWorktreesInSidebar) + )) { allNames.set(title, (allNames.get(title) ?? 0) + 1) if (groupItem.group === group) { groupNames.set(title, (groupNames.get(title) ?? 0) + 1) } } } - - return repositories - .map(r => { - const repoState = localRepositoryStateLookup.get(r.id) - const title = getDisplayTitle(r) - - return { - text: r instanceof Repository ? [title, nameOf(r)] : [title], - id: r.id.toString(), - repository: r, - needsDisambiguation: - // If the repository is in the enterprise group and has a duplicate - // name in the group, we need to disambiguate it. We don't have to - // disambiguate repositories in the 'dotcom' group because they are - // already grouped by owner. If the repository is in the 'recent' - // group and has a duplicate name in any group, we need to - // disambiguate it. - ((groupNames.get(title) ?? 0) > 1 && group.kind === 'enterprise') || - ((allNames.get(title) ?? 0) > 1 && group.kind === 'recent'), - aheadBehind: repoState?.aheadBehind ?? null, - changedFilesCount: repoState?.changedFilesCount ?? 0, - branchName: repoState?.branchName ?? null, - defaultBranchName: repoState?.defaultBranchName ?? null, - } - }) - .sort(({ repository: x }, { repository: y }) => - caseInsensitiveCompare(getDisplayTitle(x), getDisplayTitle(y)) - ) + return toSortedRepositoryListItems({ + group, + repositories, + localRepositoryStateLookup, + groupNames, + allNames, + repositoryByPath, + storedRepositoryPaths, + showWorktreesInSidebar, + }) } diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index 256a4785036..c396f5140c2 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -27,6 +27,11 @@ import { SectionFilterList } from '../lib/section-filter-list' import { assertNever } from '../../lib/fatal-error' import { IAheadBehind } from '../../models/branch' import { ShowBranchNameInRepoListSetting } from '../../models/show-branch-name-in-repo-list' +import { normalizePath } from '../../lib/helpers/path' +import { ClickSource } from '../lib/list' +import { getRepositoryType } from '../../lib/git/rev-parse' +import { FoldoutType } from '../../lib/app-state' +import { pruneWorktrees } from '../../lib/git/worktree' const BlankSlateImage = encodePathAsUrl(__dirname, 'static/empty-no-repo.svg') @@ -82,6 +87,7 @@ interface IRepositoriesListProps { /** Controls when to show the branch name next to each repository */ readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting + readonly showWorktreesInSidebar: boolean } interface IRepositoriesListState { @@ -103,9 +109,14 @@ function findMatchingListItem( selectedRepository: Repositoryish | null ) { if (selectedRepository !== null) { + const selectedPath = normalizePath(selectedRepository.path) for (const group of groups) { for (const item of group.items) { - if (item.repository.id === selectedRepository.id) { + if ( + item.repository.id === selectedRepository.id || + (item.worktreePath !== null && + normalizePath(item.worktreePath) === selectedPath) + ) { return item } } @@ -115,6 +126,36 @@ function findMatchingListItem( return null } +function isPullableRepository( + repository: Repositoryish, + repositories: ReadonlyArray +): repository is Repository { + if (!(repository instanceof Repository)) { + return false + } + + if (!repository.isLinkedWorktree) { + return true + } + + const mainWorktreePath = normalizePath(repository.mainWorktreePath) + const candidatesWithSameMain = repositories.filter( + (candidate): candidate is Repository => + candidate instanceof Repository && + normalizePath(candidate.mainWorktreePath) === mainWorktreePath + ) + + if (candidatesWithSameMain.length === 0) { + return false + } + + const preferred = + candidatesWithSameMain.find(candidate => !candidate.isLinkedWorktree) ?? + candidatesWithSameMain[0] + + return preferred.id === repository.id +} + /** The list of user-added repositories. */ export class RepositoriesList extends React.Component< IRepositoriesListProps, @@ -130,14 +171,16 @@ export class RepositoriesList extends React.Component< ( repositories: ReadonlyArray | null, localRepositoryStateLookup: ReadonlyMap, - recentRepositories: ReadonlyArray + recentRepositories: ReadonlyArray, + showWorktreesInSidebar: boolean ) => repositories === null ? [] : groupRepositories( repositories, localRepositoryStateLookup, - recentRepositories + recentRepositories, + { showWorktreesInSidebar } ) ) @@ -183,13 +226,17 @@ export class RepositoriesList extends React.Component< const repository = item.repository return ( ) } @@ -228,6 +275,9 @@ export class RepositoriesList extends React.Component< const uncommittedChangesTooltip = hasChanges ? `There are uncommitted changes in this repository.` : null + const prunableWorktreeTooltip = item.isPrunableWorktree + ? 'This worktree entry is stale and should be pruned.' + : null const ahead = aheadBehind?.ahead ?? 0 const behind = aheadBehind?.behind ?? 0 @@ -270,6 +320,16 @@ export class RepositoriesList extends React.Component< {uncommittedChangesTooltip}
)} + {prunableWorktreeTooltip && ( +
+
+ + + +
+ {prunableWorktreeTooltip} +
+ )} ) } @@ -310,7 +370,31 @@ export class RepositoriesList extends React.Component< ) } - private onItemClick = (item: IRepositoryListItem) => { + private onItemClick = (item: IRepositoryListItem, source: ClickSource) => { + if ( + source.kind === 'mouseclick' && + (source.event.button === 2 || + (__DARWIN__ && source.event.button === 0 && source.event.ctrlKey)) + ) { + return + } + + if (item.isPrunableWorktree) { + void this.props.dispatcher.postError( + new Error( + 'This worktree entry is stale. Use the context menu to prune stale worktrees.' + ) + ) + return + } + + if (item.isVirtualLinkedWorktree && item.worktreePath !== null) { + void this.onVirtualWorktreeClick(item).catch(error => + this.props.dispatcher.postError(error) + ) + return + } + const hasIndicator = item.changedFilesCount > 0 || (item.aheadBehind !== null @@ -320,20 +404,107 @@ export class RepositoriesList extends React.Component< this.props.onSelectionChanged(item.repository) } + private onVirtualWorktreeClick = async (item: IRepositoryListItem) => { + if ( + item.worktreePath === null || + item.sourceRepository === null || + !(item.repository instanceof Repository) + ) { + return + } + + const { worktreePath } = item + const existingRepo = this.props.repositories.find( + r => + r instanceof Repository && + normalizePath(r.path) === normalizePath(worktreePath) + ) + + if (existingRepo instanceof Repository) { + await this.props.dispatcher.selectRepository(existingRepo) + await this.props.dispatcher.closeFoldout(FoldoutType.Repository) + return + } + + const repositoryType = await getRepositoryType(worktreePath) + if (repositoryType.kind !== 'regular') { + throw new Error(`${worktreePath} isn't a Git repository.`) + } + + await this.props.dispatcher.selectRepository(item.repository, false) + await this.props.dispatcher.closeFoldout(FoldoutType.Repository) + } + + private onRemoveLinkedWorktree = (item: IRepositoryListItem) => { + const worktreePath = + item.worktreePath ?? + (item.repository instanceof Repository ? item.repository.path : null) + if (worktreePath === null) { + return + } + + const repository = + item.isVirtualLinkedWorktree && item.sourceRepository !== null + ? item.sourceRepository + : item.repository instanceof Repository + ? item.repository + : null + const storedRepositoryToRemove = + item.repository instanceof Repository && !item.isVirtualLinkedWorktree + ? item.repository + : undefined + + if (repository === null) { + return + } + + this.props.dispatcher.showPopup({ + type: PopupType.DeleteWorktree, + repository, + worktreePath, + storedRepositoryToRemove, + isDeletingCurrentWorktree: + this.props.selectedRepository !== null && + normalizePath(this.props.selectedRepository.path) === + normalizePath(worktreePath), + }) + } + + private onPruneStaleWorktrees = async (item: IRepositoryListItem) => { + const repository = + item.sourceRepository ?? + (item.repository instanceof Repository ? item.repository : null) + + if (repository === null) { + return + } + + await pruneWorktrees(repository) + await this.props.dispatcher.refreshRepository(repository) + } + private onItemContextMenu = ( item: IRepositoryListItem, event: React.MouseEvent ) => { event.preventDefault() + event.stopPropagation() const items = generateRepositoryListContextMenu({ onRemoveRepository: this.props.onRemoveRepository, + onRemoveLinkedWorktree: () => this.onRemoveLinkedWorktree(item), onShowRepository: this.props.onShowRepository, onOpenInNewWindow: this.props.onOpenInNewWindow, onOpenInShell: this.props.onOpenInShell, onOpenInExternalEditor: this.props.onOpenInExternalEditor, askForConfirmationOnRemoveRepository: this.props.askForConfirmationOnRemoveRepository, + isLinkedWorktreeRow: + item.isVirtualLinkedWorktree || + (item.repository instanceof Repository && + item.repository.isLinkedWorktree), + isVirtualLinkedWorktreeRow: item.isVirtualLinkedWorktree, + isPrunableWorktreeRow: item.isPrunableWorktree, externalEditorLabel: this.props.externalEditorLabel, onChangeRepositoryAlias: this.onChangeRepositoryAlias, onRemoveRepositoryAlias: this.onRemoveRepositoryAlias, @@ -343,6 +514,11 @@ export class RepositoriesList extends React.Component< repository: item.repository, shellLabel: this.props.shellLabel, onCopyRepoPath: path => this.props.dispatcher.copyPathToClipboard(path), + onPruneStaleWorktrees: () => { + void this.onPruneStaleWorktrees(item).catch(error => + this.props.dispatcher.postError(error) + ) + }, }) showContextualMenu(items) @@ -362,7 +538,8 @@ export class RepositoriesList extends React.Component< let groups = this.getRepositoryGroups( this.props.repositories, this.props.localRepositoryStateLookup, - this.props.recentRepositories + this.props.recentRepositories, + this.props.showWorktreesInSidebar ) if (!this.props.showRecentRepositories) { @@ -506,14 +683,20 @@ export class RepositoriesList extends React.Component< private onPullRepositoriesButtonClick = async () => { this.setState({ pullingRepositories: true }) try { + const repositoriesToPull = this.props.repositories.filter(repository => + isPullableRepository(repository, this.props.repositories) + ) + await Promise.all( - this.props.repositories - .filter(r => r instanceof Repository) - .map(r => - this.props.dispatcher.pull(r).catch(e => { - throw Error(`Error pulling '${r.name}' (${r.path}):\n${e}`, e) - }) - ) + repositoriesToPull.map(repository => + this.props.dispatcher.pull(repository).catch(e => { + const message = e instanceof Error ? e.message : String(e) + throw new Error( + `Error pulling '${repository.name}' (${repository.path}): ${message}`, + { cause: e } + ) + }) + ) ) } catch (e) { this.props.dispatcher.postError(e) diff --git a/app/src/ui/repositories-list/repository-list-item-context-menu.ts b/app/src/ui/repositories-list/repository-list-item-context-menu.ts index e0b2dd41fd0..d212e46c3da 100644 --- a/app/src/ui/repositories-list/repository-list-item-context-menu.ts +++ b/app/src/ui/repositories-list/repository-list-item-context-menu.ts @@ -18,12 +18,17 @@ interface IRepositoryListItemContextMenuConfig { shellLabel: string | undefined externalEditorLabel: string | undefined askForConfirmationOnRemoveRepository: boolean + readonly isLinkedWorktreeRow?: boolean + readonly isVirtualLinkedWorktreeRow?: boolean + readonly isPrunableWorktreeRow?: boolean onViewInBrowser: (repository: Repositoryish) => void onOpenInNewWindow?: (repository: Repositoryish) => void onOpenInShell: (repository: Repositoryish) => void onShowRepository: (repository: Repositoryish) => void onOpenInExternalEditor: (repository: Repositoryish) => void onRemoveRepository: (repository: Repositoryish) => void + onRemoveLinkedWorktree?: () => void + onPruneStaleWorktrees?: () => void onChangeRepositoryAlias: (repository: Repository) => void onRemoveRepositoryAlias: (repository: Repository) => void onChangeRepositoryGroupName: (repository: Repository) => void @@ -35,6 +40,11 @@ export const generateRepositoryListContextMenu = ( config: IRepositoryListItemContextMenuConfig ) => { const { repository } = config + const isLinkedWorktreeRow = config.isLinkedWorktreeRow ?? false + const isPrunableWorktreeRow = config.isPrunableWorktreeRow ?? false + const aliasMenuItems = buildAliasMenuItems(config) + const groupNameMenuItems = buildGroupNameMenuItems(config) + const identityMenuItems = [...aliasMenuItems, ...groupNameMenuItems] const missing = repository instanceof Repository && repository.missing const isGitHub = repository instanceof Repository && @@ -51,9 +61,8 @@ export const generateRepositoryListContextMenu = ( : DefaultShellLabel const items: ReadonlyArray = [ - ...buildAliasMenuItems(config), - ...buildGroupNameMenuItems(config), - { type: 'separator' }, + ...identityMenuItems, + ...(identityMenuItems.length > 0 ? [{ type: 'separator' as const }] : []), { label: __DARWIN__ ? 'Copy Repo Name' : 'Copy repo name', action: () => clipboard.writeText(repository.name), @@ -95,11 +104,39 @@ export const generateRepositoryListContextMenu = ( action: () => config.onOpenInExternalEditor(repository), enabled: !missing, }, - { type: 'separator' }, - { - label: config.askForConfirmationOnRemoveRepository ? 'Remove…' : 'Remove', - action: () => config.onRemoveRepository(repository), - }, + ...(isPrunableWorktreeRow && config.onPruneStaleWorktrees !== undefined + ? [ + { type: 'separator' as const }, + { + label: __DARWIN__ + ? 'Prune Stale Worktrees' + : 'Prune stale worktrees', + action: config.onPruneStaleWorktrees, + }, + ] + : []), + ...(!(isPrunableWorktreeRow && isLinkedWorktreeRow) + ? [ + { type: 'separator' as const }, + { + label: isPrunableWorktreeRow + ? config.askForConfirmationOnRemoveRepository + ? 'Remove…' + : 'Remove' + : isLinkedWorktreeRow + ? 'Delete…' + : config.askForConfirmationOnRemoveRepository + ? 'Remove…' + : 'Remove', + action: + !isPrunableWorktreeRow && + isLinkedWorktreeRow && + config.onRemoveLinkedWorktree !== undefined + ? config.onRemoveLinkedWorktree + : () => config.onRemoveRepository(repository), + }, + ] + : []), ] return items @@ -123,7 +160,10 @@ const buildAliasMenuItems = ( ): ReadonlyArray => { const { repository } = config - if (!(repository instanceof Repository)) { + if ( + !(repository instanceof Repository) || + config.isVirtualLinkedWorktreeRow + ) { return [] } @@ -150,7 +190,11 @@ const buildGroupNameMenuItems = ( ): ReadonlyArray => { const { repository } = config - if (!(repository instanceof Repository)) { + if ( + !(repository instanceof Repository) || + config.isLinkedWorktreeRow || + config.isVirtualLinkedWorktreeRow + ) { return [] } diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index ed6adf7d4bb..2fbf964ebac 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -14,6 +14,7 @@ import { enableAccessibleListToolTips } from '../../lib/feature-flag' import { TooltippedContent } from '../lib/tooltipped-content' interface IRepositoryListItemProps { + readonly title: string readonly repository: Repositoryish /** Does the repository need to be disambiguated in the list? */ @@ -30,6 +31,9 @@ interface IRepositoryListItemProps { /** The name of the current branch, if it should be displayed */ readonly branchName: string | null + readonly isNestedWorktree: boolean + readonly mainWorktreeName: string | null + readonly isPrunableWorktree: boolean } /** A repository item. */ @@ -44,6 +48,9 @@ export class RepositoryListItem extends React.Component< const gitHubRepo = repository instanceof Repository ? repository.gitHubRepository : null const hasChanges = this.props.changedFilesCount > 0 + const icon = this.props.isNestedWorktree + ? octicons.fileDirectory + : iconForRepository(repository) const alias: string | null = repository instanceof Repository ? repository.alias : null @@ -54,11 +61,19 @@ export class RepositoryListItem extends React.Component< } const classNameList = classNames('name', { - alias: alias !== null, + alias: + alias !== null && + !this.props.isNestedWorktree && + this.props.title === alias, }) return ( -
+
- + -
+
{prefix ? {prefix} : null}
@@ -85,7 +97,7 @@ export class RepositoryListItem extends React.Component< {this.props.branchName} )} - + {this.props.isPrunableWorktree && renderPrunableIndicator()} {repository instanceof Repository && renderRepoIndicators({ aheadBehind: this.props.aheadBehind, @@ -109,6 +121,12 @@ export class RepositoryListItem extends React.Component<
{repo.path}
{this.props.branchName &&
Branch: {this.props.branchName}
} + {this.props.mainWorktreeName && ( +
Repository: {this.props.mainWorktreeName}
+ )} + {this.props.isPrunableWorktree && ( +
This worktree entry is stale and should be pruned.
+ )} ) } @@ -121,7 +139,14 @@ export class RepositoryListItem extends React.Component< return ( nextProps.repository.id !== this.props.repository.id || nextProps.matches !== this.props.matches || - nextProps.branchName !== this.props.branchName + nextProps.title !== this.props.title || + nextProps.needsDisambiguation !== this.props.needsDisambiguation || + nextProps.branchName !== this.props.branchName || + nextProps.aheadBehind !== this.props.aheadBehind || + nextProps.changedFilesCount !== this.props.changedFilesCount || + nextProps.isNestedWorktree !== this.props.isNestedWorktree || + nextProps.mainWorktreeName !== this.props.mainWorktreeName || + nextProps.isPrunableWorktree !== this.props.isPrunableWorktree ) } else { return true @@ -179,5 +204,17 @@ const renderChangesIndicator = () => { ) } +const renderPrunableIndicator = () => { + return ( + + + + ) +} + export const commitGrammar = (commitNum: number) => `${commitNum} commit${commitNum > 1 ? 's' : ''}` // english is hard diff --git a/app/src/ui/repositories-list/worktree-list-items.ts b/app/src/ui/repositories-list/worktree-list-items.ts new file mode 100644 index 00000000000..e0eb32dbc31 --- /dev/null +++ b/app/src/ui/repositories-list/worktree-list-items.ts @@ -0,0 +1,327 @@ +import * as Path from 'path' + +import { + Repository, + ILocalRepositoryState, + nameOf, +} from '../../models/repository' +import { caseInsensitiveCompare } from '../../lib/compare' +import { normalizePath } from '../../lib/helpers/path' +import type { IAheadBehind } from '../../models/branch' +import type { WorktreeEntry } from '../../models/worktree' +import type { + IRepositoryListItem, + RepositoryListGroup, + Repositoryish, +} from './group-repositories' + +let nextVirtualRepositoryId = -1 +const virtualRepositoryIdsByPath = new Map() + +export const getDisplayTitle = (repository: Repositoryish) => + repository instanceof Repository && repository.alias != null + ? repository.alias + : repository.name + +const getLinkedWorktreeDisplayTitle = ( + repository: Repositoryish, + worktreePath?: string +) => + repository instanceof Repository && repository.alias != null + ? repository.alias + : Path.basename(worktreePath ?? repository.path) + +export const getRepositoryListTitle = ( + repository: Repositoryish, + showWorktreesInSidebar: boolean +) => + showWorktreesInSidebar && + repository instanceof Repository && + repository.isLinkedWorktree + ? getLinkedWorktreeDisplayTitle(repository) + : getDisplayTitle(repository) + +const getVirtualRepositoryId = (worktreePath: string) => { + const normalizedPath = normalizePath(worktreePath) + const existingId = virtualRepositoryIdsByPath.get(normalizedPath) + if (existingId !== undefined) { + return existingId + } + + const id = nextVirtualRepositoryId-- + virtualRepositoryIdsByPath.set(normalizedPath, id) + return id +} + +const pruneVirtualRepositoryIds = ( + storedRepositoryPaths: ReadonlySet, + localRepositoryStateLookup: ReadonlyMap +) => { + const knownWorktreePaths = new Set(storedRepositoryPaths) + + for (const state of localRepositoryStateLookup.values()) { + for (const worktree of state.allWorktrees) { + knownWorktreePaths.add(normalizePath(worktree.path)) + } + } + + for (const worktreePath of virtualRepositoryIdsByPath.keys()) { + if (!knownWorktreePaths.has(worktreePath)) { + virtualRepositoryIdsByPath.delete(worktreePath) + } + } +} + +const getBranchNameForWorktree = (worktree: WorktreeEntry) => + worktree.branch?.replace(/^refs\/heads\//, '') ?? null + +const getWorktreeEntryForPath = ( + allWorktrees: ReadonlyArray, + worktreePath: string +) => + allWorktrees.find( + worktree => normalizePath(worktree.path) === normalizePath(worktreePath) + ) ?? null + +interface IToListItemOptions { + readonly isVirtualLinkedWorktree?: boolean + readonly worktreePath?: string + readonly sourceRepository?: Repository | null + readonly branchName?: string | null + readonly changedFilesCount?: number + readonly aheadBehind?: IAheadBehind | null +} + +interface IToSortedListItemsOptions { + readonly group: RepositoryListGroup + readonly repositories: ReadonlyArray + readonly localRepositoryStateLookup: ReadonlyMap< + number, + ILocalRepositoryState + > + readonly groupNames: ReadonlyMap + readonly allNames: ReadonlyMap + readonly repositoryByPath: ReadonlyMap + readonly storedRepositoryPaths: ReadonlySet + readonly showWorktreesInSidebar: boolean +} + +export function toSortedRepositoryListItems({ + group, + repositories, + localRepositoryStateLookup, + groupNames, + allNames, + repositoryByPath, + storedRepositoryPaths, + showWorktreesInSidebar, +}: IToSortedListItemsOptions): IRepositoryListItem[] { + pruneVirtualRepositoryIds(storedRepositoryPaths, localRepositoryStateLookup) + + const toListItem = ( + repository: Repositoryish, + isNestedWorktree: boolean, + options?: IToListItemOptions + ): IRepositoryListItem => { + const repoState = localRepositoryStateLookup.get(repository.id) + const isVirtualLinkedWorktree = options?.isVirtualLinkedWorktree ?? false + const isLinkedWorktree = + !isVirtualLinkedWorktree && + repository instanceof Repository && + repository.isLinkedWorktree + const worktreePath = options?.worktreePath ?? repository.path + const parentRepository = + options?.sourceRepository ?? + (repository instanceof Repository && isLinkedWorktree + ? repositoryByPath.get(normalizePath(repository.mainWorktreePath)) ?? + null + : null) + const parentRepoState = + parentRepository !== null + ? localRepositoryStateLookup.get(parentRepository.id) + : null + const startupWorktreeEntry = + (isLinkedWorktree || isVirtualLinkedWorktree) && parentRepoState != null + ? getWorktreeEntryForPath(parentRepoState.allWorktrees, worktreePath) + : null + const title = + isLinkedWorktree || isVirtualLinkedWorktree + ? getLinkedWorktreeDisplayTitle(repository, worktreePath) + : getDisplayTitle(repository) + const defaultBranchName = + repoState?.defaultBranchName ?? + options?.sourceRepository?.defaultBranch ?? + (repository instanceof Repository ? repository.defaultBranch : null) + const mainWorktreePath = + isVirtualLinkedWorktree && options?.sourceRepository != null + ? options.sourceRepository.mainWorktreePath + : repository instanceof Repository + ? repository.mainWorktreePath + : options?.sourceRepository?.mainWorktreePath ?? repository.path + const mainWorktreeName = + (isLinkedWorktree || isVirtualLinkedWorktree) && isNestedWorktree + ? Path.basename(mainWorktreePath) + : null + + return { + text: + repository instanceof Repository + ? isLinkedWorktree || isVirtualLinkedWorktree + ? [title, nameOf(repository), Path.basename(mainWorktreePath)] + : [title, nameOf(repository)] + : [title], + title, + id: options?.worktreePath + ? `worktree:${normalizePath(options.worktreePath)}` + : repository.id.toString(), + repository, + needsDisambiguation: + ((groupNames.get(title) ?? 0) > 1 && group.kind === 'enterprise') || + ((allNames.get(title) ?? 0) > 1 && group.kind === 'recent'), + aheadBehind: options?.aheadBehind ?? repoState?.aheadBehind ?? null, + changedFilesCount: + options?.changedFilesCount ?? repoState?.changedFilesCount ?? 0, + branchName: + options?.branchName ?? + repoState?.branchName ?? + (startupWorktreeEntry + ? getBranchNameForWorktree(startupWorktreeEntry) + : null), + defaultBranchName, + isNestedWorktree, + mainWorktreeName, + isVirtualLinkedWorktree, + isPrunableWorktree: startupWorktreeEntry?.isPrunable ?? false, + worktreePath: options?.worktreePath ?? null, + sourceRepository: options?.sourceRepository ?? parentRepository, + } + } + + const appendVirtualWorktreeItems = ( + items: IRepositoryListItem[], + repository: Repository, + sourceRepository: Repository, + emittedVirtualPaths: Set + ) => { + const repoState = localRepositoryStateLookup.get(repository.id) + const allWorktrees = repoState?.allWorktrees ?? [] + const excludedPaths = new Set([ + ...storedRepositoryPaths, + ...emittedVirtualPaths, + normalizePath(repository.path), + ]) + const virtualWorktrees = allWorktrees + .filter( + worktree => + worktree.type === 'linked' && + !excludedPaths.has(normalizePath(worktree.path)) + ) + .sort((x, y) => + caseInsensitiveCompare(Path.basename(x.path), Path.basename(y.path)) + ) + + for (const worktree of virtualWorktrees) { + const virtualRepositoryPath = normalizePath(worktree.path) + const virtualRepository = new Repository( + worktree.path, + getVirtualRepositoryId(virtualRepositoryPath), + sourceRepository.gitHubRepository, + false, + null, + sourceRepository.groupName, + sourceRepository.defaultBranch, + sourceRepository.workflowPreferences, + sourceRepository.customEditorOverride, + sourceRepository.isTutorialRepository, + sourceRepository.overrideLogin + ) + + items.push( + toListItem(virtualRepository, true, { + isVirtualLinkedWorktree: true, + worktreePath: worktree.path, + sourceRepository, + branchName: getBranchNameForWorktree(worktree), + changedFilesCount: 0, + aheadBehind: null, + }) + ) + emittedVirtualPaths.add(virtualRepositoryPath) + } + } + + const sortedRepositories = [...repositories].sort((x, y) => + caseInsensitiveCompare( + getRepositoryListTitle(x, showWorktreesInSidebar), + getRepositoryListTitle(y, showWorktreesInSidebar) + ) + ) + + if (!showWorktreesInSidebar || group.kind === 'recent') { + return sortedRepositories.map(repository => toListItem(repository, false)) + } + + const mainRepos: Repositoryish[] = [] + const orphanLinkedRepos: Repository[] = [] + const linkedReposByParentPath = new Map() + + for (const repository of sortedRepositories) { + if (!(repository instanceof Repository) || !repository.isLinkedWorktree) { + mainRepos.push(repository) + continue + } + + const parentPath = normalizePath(repository.mainWorktreePath) + const linkedRepos = linkedReposByParentPath.get(parentPath) + if (linkedRepos !== undefined) { + linkedRepos.push(repository) + } else { + linkedReposByParentPath.set(parentPath, [repository]) + } + } + + const items: IRepositoryListItem[] = [] + const seenLinkedRepoIds = new Set() + const emittedVirtualPaths = new Set() + + for (const repository of mainRepos) { + items.push(toListItem(repository, false)) + + if (!(repository instanceof Repository)) { + continue + } + + const linkedRepos = linkedReposByParentPath.get( + normalizePath(repository.path) + ) + if (linkedRepos !== undefined) { + for (const linkedRepo of linkedRepos) { + seenLinkedRepoIds.add(linkedRepo.id) + items.push(toListItem(linkedRepo, true)) + } + } + + appendVirtualWorktreeItems( + items, + repository, + repository, + emittedVirtualPaths + ) + } + + for (const repository of sortedRepositories) { + if ( + repository instanceof Repository && + repository.isLinkedWorktree && + !seenLinkedRepoIds.has(repository.id) + ) { + orphanLinkedRepos.push(repository) + } + } + + for (const repository of orphanLinkedRepos) { + items.push(toListItem(repository, false)) + } + + return items +} diff --git a/app/src/ui/worktrees/delete-worktree-dialog.tsx b/app/src/ui/worktrees/delete-worktree-dialog.tsx index d30c2ccd239..933886a2a21 100644 --- a/app/src/ui/worktrees/delete-worktree-dialog.tsx +++ b/app/src/ui/worktrees/delete-worktree-dialog.tsx @@ -16,6 +16,8 @@ import { interface IDeleteWorktreeDialogProps { readonly repository: Repository readonly worktreePath: string + readonly storedRepositoryToRemove?: Repository + readonly isDeletingCurrentWorktree?: boolean readonly dispatcher: Dispatcher readonly onDismissed: () => void } @@ -66,9 +68,13 @@ export class DeleteWorktreeDialog extends React.Component< private onDeleteWorktree = async () => { this.setState({ isDeleting: true }) - const { repository, worktreePath, dispatcher } = this.props - const isDeletingCurrentWorktree = - normalizePath(repository.path) === normalizePath(worktreePath) + const { + repository, + worktreePath, + dispatcher, + storedRepositoryToRemove, + isDeletingCurrentWorktree = false, + } = this.props const mainPathForCleanup = await getMainWorktreePath(repository) @@ -94,9 +100,13 @@ export class DeleteWorktreeDialog extends React.Component< const mainRepo = addedRepos[0] await dispatcher.selectRepository(mainRepo) await removeWorktree(mainRepo, worktreePath) - await dispatcher.removeRepository(repository, false) } else { await removeWorktree(repository, worktreePath) + } + + if (storedRepositoryToRemove !== undefined) { + await dispatcher.removeRepository(storedRepositoryToRemove, false) + } else if (!isDeletingCurrentWorktree) { await dispatcher.refreshRepository(repository) } } catch (e) { diff --git a/app/styles/ui/_repository-list.scss b/app/styles/ui/_repository-list.scss index aa7dcf31ab1..10d66848223 100644 --- a/app/styles/ui/_repository-list.scss +++ b/app/styles/ui/_repository-list.scss @@ -34,6 +34,10 @@ // name and truncate accordingly width: 100%; + &.nested-worktree { + padding-left: calc(var(--spacing) + 20px); + } + .icon-for-repository { // Some room between the icon and repository name margin-right: var(--spacing-half); @@ -88,6 +92,13 @@ vertical-align: text-bottom; } } + + .worktrees-loading { + color: var(--text-secondary-color); + font-size: var(--font-size-sm); + margin-left: var(--spacing); + flex-shrink: 0; + } } .filter-list-group-header { @@ -216,6 +227,19 @@ } } + .prunable-indicator-wrapper { + display: flex; + min-width: 12px; + justify-content: center; + align-items: center; + margin-left: var(--spacing-half); + + .octicon { + color: var(--toolbar-dropdown-text-warning-color); + width: auto; + } + } + .ahead-behind { height: 16px; background: var(--list-item-badge-background-color); diff --git a/app/test/unit/repositories-list-grouping-test.ts b/app/test/unit/repositories-list-grouping-test.ts index 2de41d25bed..90c6087e31d 100644 --- a/app/test/unit/repositories-list-grouping-test.ts +++ b/app/test/unit/repositories-list-grouping-test.ts @@ -1,9 +1,13 @@ import { describe, it } from 'node:test' import assert from 'node:assert' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { groupRepositories } from '../../src/ui/repositories-list/group-repositories' import { Repository, ILocalRepositoryState } from '../../src/models/repository' import { CloningRepository } from '../../src/models/cloning-repository' import { gitHubRepoFixture } from '../helpers/github-repo-builder' +import { WorktreeEntry } from '../../src/models/worktree' describe('repository list grouping', () => { const repositories: Array = [ @@ -153,4 +157,325 @@ describe('repository list grouping', () => { assert.equal(grouped[2].items[1].text[0], 'enterprise-repo') assert(grouped[2].items[1].needsDisambiguation) }) + + it('nests linked worktrees under their main repository in non-recent groups', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-grouping-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const linkedRepoPath = path.join(tempRoot, 'repo-feature-worktree') + + await mkdir(path.join(mainRepoPath, '.git'), { recursive: true }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'fix-node'), { + recursive: true, + }) + await mkdir(linkedRepoPath, { recursive: true }) + await writeFile( + path.join(linkedRepoPath, '.git'), + 'gitdir: ../repo/.git/worktrees/fix-node\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'fix-node', 'commondir'), + '../..\n' + ) + + const mainRepo = new Repository( + mainRepoPath, + 1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + const linkedRepo = new Repository( + linkedRepoPath, + 2, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const grouped = groupRepositories([linkedRepo, mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[0].repository.path, mainRepoPath) + assert.equal(grouped[0].items[0].isNestedWorktree, false) + assert.equal(grouped[0].items[1].repository.path, linkedRepoPath) + assert.equal(grouped[0].items[1].isNestedWorktree, true) + assert.equal(grouped[0].items[1].mainWorktreeName, 'repo') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('shows unstored linked worktrees under their main repository', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-virtual-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const unstoredWorktreePath = path.join(tempRoot, 'repo-feature-a') + const mainRepo = new Repository( + mainRepoPath, + 10, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const allWorktrees: ReadonlyArray = [ + { + path: mainRepoPath, + head: 'a', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: unstoredWorktreePath, + head: 'b', + branch: 'refs/heads/feature/a', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + ] + + cache.set(mainRepo.id, { + aheadBehind: null, + changedFilesCount: 0, + branchName: 'main', + defaultBranchName: 'main', + allWorktrees, + }) + + const grouped = groupRepositories([mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[0].repository.path, mainRepoPath) + assert.equal(grouped[0].items[1].worktreePath, unstoredWorktreePath) + assert.equal(grouped[0].items[1].isVirtualLinkedWorktree, true) + assert.equal(grouped[0].items[1].text[0], 'repo-feature-a') + assert.equal(grouped[0].items[1].branchName, 'feature/a') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('marks prunable linked worktrees in the sidebar item model', () => { + const mainRepo = new Repository( + '/tmp/repo', + 1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const cache = new Map() + cache.set(mainRepo.id, { + changedFilesCount: 0, + aheadBehind: null, + branchName: 'main', + defaultBranchName: 'main', + allWorktrees: [ + { + path: '/tmp/repo', + head: 'abc', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: '/tmp/repo-stale', + head: 'def', + branch: 'refs/heads/feature/stale', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: true, + }, + ], + }) + + const grouped = groupRepositories([mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[1].isPrunableWorktree, true) + }) + + it('uses parent preloaded worktree data for stored linked worktree branch names', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-branch-fallback-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const linkedRepoPath = path.join(tempRoot, 'repo-feature-a') + + await mkdir(path.join(mainRepoPath, '.git'), { recursive: true }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'feature-a'), { + recursive: true, + }) + await mkdir(linkedRepoPath, { recursive: true }) + await writeFile( + path.join(linkedRepoPath, '.git'), + 'gitdir: ../repo/.git/worktrees/feature-a\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'feature-a', 'commondir'), + '../..\n' + ) + + const mainRepo = new Repository( + mainRepoPath, + 12, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + const linkedRepo = new Repository( + linkedRepoPath, + 13, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + const allWorktrees: ReadonlyArray = [ + { + path: mainRepoPath, + head: 'a', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: linkedRepoPath, + head: 'b', + branch: 'refs/heads/feature/a', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + ] + + cache.set(mainRepo.id, { + aheadBehind: null, + changedFilesCount: 0, + branchName: 'main', + defaultBranchName: 'main', + allWorktrees, + }) + + const grouped = groupRepositories([linkedRepo, mainRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 2) + assert.equal(grouped[0].items[1].repository.path, linkedRepoPath) + assert.equal(grouped[0].items[1].isNestedWorktree, true) + assert.equal(grouped[0].items[1].branchName, 'feature/a') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) + + it('does not synthesize linked worktree siblings under orphan linked worktrees', async () => { + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'github-desktop-plus-worktree-orphan-leaf-') + ) + try { + const mainRepoPath = path.join(tempRoot, 'repo') + const linkedRepoPath = path.join(tempRoot, 'repo-feature-a') + const secondLinkedRepoPath = path.join(tempRoot, 'repo-feature-b') + + await mkdir(path.join(mainRepoPath, '.git'), { recursive: true }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'feature-a'), { + recursive: true, + }) + await mkdir(path.join(mainRepoPath, '.git', 'worktrees', 'feature-b'), { + recursive: true, + }) + await mkdir(linkedRepoPath, { recursive: true }) + await writeFile( + path.join(linkedRepoPath, '.git'), + 'gitdir: ../repo/.git/worktrees/feature-a\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'feature-a', 'commondir'), + '../..\n' + ) + await writeFile( + path.join(mainRepoPath, '.git', 'worktrees', 'feature-b', 'commondir'), + '../..\n' + ) + + const linkedRepo = new Repository( + linkedRepoPath, + 20, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false, + 'custom alias' + ) + + cache.set(linkedRepo.id, { + aheadBehind: null, + changedFilesCount: 0, + branchName: 'feature/a', + defaultBranchName: 'main', + allWorktrees: [ + { + path: mainRepoPath, + head: 'a', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }, + { + path: linkedRepoPath, + head: 'b', + branch: 'refs/heads/feature/a', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + { + path: secondLinkedRepoPath, + head: 'c', + branch: 'refs/heads/feature/b', + isDetached: false, + type: 'linked', + isLocked: false, + isPrunable: false, + }, + ], + }) + + const grouped = groupRepositories([linkedRepo], cache, [], { + showWorktreesInSidebar: true, + }) + + assert.equal(grouped.length, 1) + assert.equal(grouped[0].items.length, 1) + assert.equal(grouped[0].items[0].repository.path, linkedRepoPath) + assert.equal(grouped[0].items[0].isNestedWorktree, false) + assert.equal(grouped[0].items[0].title, 'custom alias') + } finally { + await rm(tempRoot, { recursive: true, force: true }) + } + }) }) diff --git a/app/test/unit/repositories-store-test.ts b/app/test/unit/repositories-store-test.ts index 5fa00ce517f..a54af384a27 100644 --- a/app/test/unit/repositories-store-test.ts +++ b/app/test/unit/repositories-store-test.ts @@ -3,7 +3,11 @@ import assert from 'node:assert' import { RepositoriesStore } from '../../src/lib/stores/repositories-store' import { TestRepositoriesDatabase } from '../helpers/databases' import { IAPIFullRepository, getDotComAPIEndpoint } from '../../src/lib/api' -import { assertIsRepositoryWithGitHubRepository } from '../../src/models/repository' +import { + assertIsRepositoryWithGitHubRepository, + Repository, +} from '../../src/models/repository' +import { gitHubRepoFixture } from '../helpers/github-repo-builder' describe('RepositoriesStore', () => { let repoDb = new TestRepositoriesDatabase() @@ -97,4 +101,22 @@ describe('RepositoriesStore', () => { ) }) }) + + describe('stash check tracking', () => { + it('ignores transient synthetic repositories', async () => { + const syntheticRepo = new Repository( + '/tmp/repo-feature-a', + -1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false + ) + + assert.equal( + await repositoriesStore.getLastStashCheckDate(syntheticRepo), + null + ) + + await repositoriesStore.updateLastStashCheckDate(syntheticRepo) + }) + }) }) diff --git a/app/test/unit/repository-list-item-context-menu-test.ts b/app/test/unit/repository-list-item-context-menu-test.ts new file mode 100644 index 00000000000..a8dd9c7fe22 --- /dev/null +++ b/app/test/unit/repository-list-item-context-menu-test.ts @@ -0,0 +1,179 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { Repository } from '../../src/models/repository' +import { generateRepositoryListContextMenu } from '../../src/ui/repositories-list/repository-list-item-context-menu' +import { gitHubRepoFixture } from '../helpers/github-repo-builder' + +describe('repository list item context menu', () => { + const buildConfig = ( + overrides: Partial< + Parameters[0] + > = {} + ) => { + const repository = + overrides.repository ?? + new Repository( + '/tmp/repo', + 1, + gitHubRepoFixture({ owner: 'example', name: 'repo' }), + false, + 'alias', + 'group' + ) + + return { + repository, + shellLabel: undefined, + externalEditorLabel: undefined, + askForConfirmationOnRemoveRepository: true, + onViewInBrowser: () => {}, + onOpenInNewWindow: () => {}, + onOpenInShell: () => {}, + onShowRepository: () => {}, + onOpenInExternalEditor: () => {}, + onRemoveRepository: () => {}, + onChangeRepositoryAlias: () => {}, + onRemoveRepositoryAlias: () => {}, + onChangeRepositoryGroupName: () => {}, + onRemoveRepositoryGroupName: () => {}, + onCopyRepoPath: () => {}, + ...overrides, + } + } + + it('shows alias and group name actions for normal repository rows', () => { + const items = generateRepositoryListContextMenu(buildConfig()) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert(labels.includes('Change alias') || labels.includes('Change Alias')) + assert(labels.includes('Remove alias') || labels.includes('Remove Alias')) + assert( + labels.includes('Change group name') || + labels.includes('Change Group Name') + ) + assert( + labels.includes('Restore group name') || + labels.includes('Restore Group Name') + ) + assert(labels.includes('Remove…')) + }) + + it('hides alias and group name actions for linked worktree rows and deletes the worktree', () => { + let removedRepository = false + let removedLinkedWorktree = false + + const items = generateRepositoryListContextMenu( + buildConfig({ + isLinkedWorktreeRow: true, + isVirtualLinkedWorktreeRow: true, + onRemoveRepository: () => { + removedRepository = true + }, + onRemoveLinkedWorktree: () => { + removedLinkedWorktree = true + }, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert(!labels.includes('Change alias')) + assert(!labels.includes('Change Alias')) + assert(!labels.includes('Remove alias')) + assert(!labels.includes('Remove Alias')) + assert(!labels.includes('Change group name')) + assert(!labels.includes('Change Group Name')) + assert(!labels.includes('Restore group name')) + assert(!labels.includes('Restore Group Name')) + assert(labels.includes('Delete…')) + + const deleteItem = items.find( + (item): item is { label: string; action: () => void } => + 'label' in item && item.label === 'Delete…' + ) + assert(deleteItem !== undefined) + + deleteItem.action() + + assert.equal(removedLinkedWorktree, true) + assert.equal(removedRepository, false) + }) + + it('keeps alias and group name actions for saved linked worktree rows', () => { + const items = generateRepositoryListContextMenu( + buildConfig({ + isLinkedWorktreeRow: true, + isVirtualLinkedWorktreeRow: false, + onRemoveLinkedWorktree: () => {}, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert(labels.includes('Change alias') || labels.includes('Change Alias')) + assert(labels.includes('Remove alias') || labels.includes('Remove Alias')) + assert(!labels.includes('Change group name')) + assert(!labels.includes('Change Group Name')) + assert(!labels.includes('Restore group name')) + assert(!labels.includes('Restore Group Name')) + assert(labels.includes('Delete…')) + assert(!labels.includes('Remove…')) + }) + + it('shows a prune action for stale worktree rows and keeps remove semantics', () => { + let prunedStaleWorktrees = false + let removedRepository = false + + const items = generateRepositoryListContextMenu( + buildConfig({ + isPrunableWorktreeRow: true, + onPruneStaleWorktrees: () => { + prunedStaleWorktrees = true + }, + onRemoveRepository: () => { + removedRepository = true + }, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert( + labels.includes('Prune stale worktrees') || + labels.includes('Prune Stale Worktrees') + ) + assert(labels.includes('Remove…')) + assert(!labels.includes('Delete…')) + + const pruneItem = items.find( + (item): item is { label: string; action: () => void } => + 'label' in item && + (item.label === 'Prune stale worktrees' || + item.label === 'Prune Stale Worktrees') + ) + assert(pruneItem !== undefined) + + pruneItem.action() + + assert.equal(prunedStaleWorktrees, true) + assert.equal(removedRepository, false) + }) + + it('shows only prune for stale virtual worktree rows', () => { + const items = generateRepositoryListContextMenu( + buildConfig({ + isLinkedWorktreeRow: true, + isVirtualLinkedWorktreeRow: true, + isPrunableWorktreeRow: true, + onPruneStaleWorktrees: () => {}, + }) + ) + const labels = items.flatMap(item => ('label' in item ? [item.label] : [])) + + assert.equal('type' in items[0] && items[0].type === 'separator', false) + assert( + labels.includes('Prune stale worktrees') || + labels.includes('Prune Stale Worktrees') + ) + assert(!labels.includes('Delete…')) + assert(!labels.includes('Remove…')) + }) +})