diff --git a/app/src/lib/databases/repositories-database.ts b/app/src/lib/databases/repositories-database.ts index cb777337dcd..106c026968a 100644 --- a/app/src/lib/databases/repositories-database.ts +++ b/app/src/lib/databases/repositories-database.ts @@ -44,6 +44,7 @@ export interface IDatabaseRepository { readonly id?: number readonly gitHubRepositoryID: number | null readonly path: string + readonly alias: string | null readonly missing: boolean /** The last time the stash entries were checked for the repository */ diff --git a/app/src/lib/feature-flag.ts b/app/src/lib/feature-flag.ts index d1ef18d4f44..b457c9b8b83 100644 --- a/app/src/lib/feature-flag.ts +++ b/app/src/lib/feature-flag.ts @@ -175,3 +175,8 @@ export function enableUpdateFromRosettaToARM64(): boolean { export function enableSaveDialogOnCloneRepository(): boolean { return enableBetaFeatures() } + +/** Should we allow setting repository aliases? */ +export function enableRepositoryAliases(): boolean { + return enableBetaFeatures() +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 118ed050357..d5a87e1ae6b 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -1451,6 +1451,13 @@ export class AppStore extends TypedBaseStore { previousRepositoryId: number | null, currentRepositoryId: number ) { + // No need to update the recent repositories if the selected repository is + // the same as the old one (this could happen when the alias of the selected + // repository is changed). + if (previousRepositoryId === currentRepositoryId) { + return + } + const recentRepositories = getNumberArray(RecentRepositoriesKey).filter( el => el !== currentRepositoryId && el !== previousRepositoryId ) @@ -3408,6 +3415,14 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + /** This shouldn't be called directly. See `Dispatcher`. */ + public async _changeRepositoryAlias( + repository: Repository, + newAlias: string | null + ): Promise { + return this.repositoriesStore.updateRepositoryAlias(repository, newAlias) + } + /** This shouldn't be called directly. See `Dispatcher`. */ public async _renameBranch( repository: Repository, diff --git a/app/src/lib/stores/repositories-store.ts b/app/src/lib/stores/repositories-store.ts index 1a35762be46..44a4a3c0c6c 100644 --- a/app/src/lib/stores/repositories-store.ts +++ b/app/src/lib/stores/repositories-store.ts @@ -22,6 +22,7 @@ import { WorkflowPreferences } from '../../models/workflow-preferences' import { clearTagsToPush } from './helpers/tags-to-push-storage' import { IMatchedGitHubRepository } from '../repository-matching' import { shallowEquals } from '../equality' +import { enableRepositoryAliases } from '../feature-flag' /** The store for local repositories. */ export class RepositoriesStore extends TypedBaseStore< @@ -132,6 +133,7 @@ export class RepositoriesStore extends TypedBaseStore< ? await this.findGitHubRepositoryByID(repo.gitHubRepositoryID) : await Promise.resolve(null), // Dexie gets confused if we return null repo.missing, + enableRepositoryAliases() ? repo.alias : null, repo.workflowPreferences, repo.isTutorialRepository ) @@ -194,6 +196,7 @@ export class RepositoriesStore extends TypedBaseStore< return await this.db.repositories.put({ ...(existingRepo?.id !== undefined && { id: existingRepo.id }), path, + alias: null, gitHubRepositoryID: ghRepo.dbID, missing: false, lastStashCheckDate: null, @@ -228,6 +231,7 @@ export class RepositoriesStore extends TypedBaseStore< gitHubRepositoryID: null, missing: false, lastStashCheckDate: null, + alias: null, } const id = await this.db.repositories.add(dbRepo) return this.toRepository({ id, ...dbRepo }) @@ -261,11 +265,27 @@ export class RepositoriesStore extends TypedBaseStore< repository.id, repository.gitHubRepository, missing, + repository.alias, repository.workflowPreferences, repository.isTutorialRepository ) } + /** + * Update the alias for the specified repository. + * + * @param repository The repository to update. + * @param alias The new alias to use. + */ + public async updateRepositoryAlias( + repository: Repository, + alias: string | null + ): Promise { + await this.db.repositories.update(repository.id, { alias }) + + this.emitUpdatedRepositories() + } + /** * Update the workflow preferences for the specified repository. * @@ -295,6 +315,7 @@ export class RepositoriesStore extends TypedBaseStore< repository.id, repository.gitHubRepository, false, + repository.alias, repository.workflowPreferences, repository.isTutorialRepository ) @@ -421,6 +442,7 @@ export class RepositoriesStore extends TypedBaseStore< repo.id, ghRepo, repo.missing, + repo.alias, repo.workflowPreferences, repo.isTutorialRepository ) diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 7c38dcfc939..b04af27bb86 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -69,6 +69,7 @@ export enum PopupType { ConfirmDiscardSelection, CherryPick, MoveToApplicationsFolder, + ChangeRepositoryAlias, } export type Popup = @@ -276,3 +277,4 @@ export type Popup = sourceBranch: Branch | null } | { type: PopupType.MoveToApplicationsFolder } + | { type: PopupType.ChangeRepositoryAlias; repository: Repository } diff --git a/app/src/models/repository.ts b/app/src/models/repository.ts index 7fe6623c48c..e299d051bdd 100644 --- a/app/src/models/repository.ts +++ b/app/src/models/repository.ts @@ -51,6 +51,7 @@ export class Repository { public readonly id: number, public readonly gitHubRepository: GitHubRepository | null, public readonly missing: boolean, + public readonly alias: string | null = null, public readonly workflowPreferences: WorkflowPreferences = {}, /** * True if the repository is a tutorial repository created as part of the @@ -67,6 +68,7 @@ export class Repository { this.id, gitHubRepository?.hash, this.missing, + this.alias, this.workflowPreferences.forkContributionTarget, this.isTutorialRepository ) diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index e750d612970..a4e9c892356 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -135,6 +135,7 @@ import { CherryPickCommit } from './drag-elements/cherry-pick-commit' import classNames from 'classnames' import { dragAndDropManager } from '../lib/drag-and-drop-manager' import { MoveToApplicationsFolder } from './move-to-applications-folder' +import { ChangeRepositoryAlias } from './change-repository-alias/change-repository-alias-dialog' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2048,6 +2049,15 @@ export class App extends React.Component { /> ) } + case PopupType.ChangeRepositoryAlias: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } @@ -2367,8 +2377,9 @@ export class App extends React.Component { let icon: OcticonSymbol let title: string if (repository) { + const alias = repository instanceof Repository ? repository.alias : null icon = iconForRepository(repository) - title = repository.name + title = alias ?? repository.name } else if (this.state.repositories.length > 0) { icon = OcticonSymbol.repo title = __DARWIN__ ? 'Select a Repository' : 'Select a repository' diff --git a/app/src/ui/change-repository-alias/change-repository-alias-dialog.tsx b/app/src/ui/change-repository-alias/change-repository-alias-dialog.tsx new file mode 100644 index 00000000000..26ec91cd719 --- /dev/null +++ b/app/src/ui/change-repository-alias/change-repository-alias-dialog.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' + +import { Dispatcher } from '../dispatcher' +import { nameOf, Repository } from '../../models/repository' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { TextBox } from '../lib/text-box' + +interface IChangeRepositoryAliasProps { + readonly dispatcher: Dispatcher + readonly onDismissed: () => void + readonly repository: Repository +} + +interface IChangeRepositoryAliasState { + readonly newAlias: string +} + +export class ChangeRepositoryAlias extends React.Component< + IChangeRepositoryAliasProps, + IChangeRepositoryAliasState +> { + public constructor(props: IChangeRepositoryAliasProps) { + super(props) + + this.state = { newAlias: props.repository.alias ?? props.repository.name } + } + + public render() { + const repository = this.props.repository + const verb = repository.alias === null ? 'Create' : 'Change' + + return ( + + +

Choose a new alias for the repository "{nameOf(repository)}".

+

+ +

+ {repository.gitHubRepository !== null && ( +

+ This will not affect the original repository name on GitHub. +

+ )} +
+ + + + +
+ ) + } + + private onNameChanged = (newAlias: string) => { + this.setState({ newAlias }) + } + + private changeAlias = () => { + this.props.dispatcher.changeRepositoryAlias( + this.props.repository, + this.state.newAlias + ) + this.props.onDismissed() + } +} diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 5f5b89e4bb4..dffa76257a0 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -695,6 +695,14 @@ export class Dispatcher { }) } + /** Changes the repository alias to a new name. */ + public changeRepositoryAlias( + repository: Repository, + newAlias: string | null + ): Promise { + return this.appStore._changeRepositoryAlias(repository, newAlias) + } + /** Rename the branch to a new name. */ public renameBranch( repository: Repository, diff --git a/app/src/ui/repositories-list/group-repositories.ts b/app/src/ui/repositories-list/group-repositories.ts index 4f6b4e36b20..f11b5f1776b 100644 --- a/app/src/ui/repositories-list/group-repositories.ts +++ b/app/src/ui/repositories-list/group-repositories.ts @@ -89,7 +89,7 @@ export function groupRepositories( const { aheadBehind, changedFilesCount } = localRepositoryStateLookup.get(r.id) || fallbackValue const repositoryText = - r instanceof Repository ? [r.name, nameOf(r)] : [r.name] + r instanceof Repository ? [r.alias ?? r.name, nameOf(r)] : [r.name] return { text: repositoryText, @@ -132,8 +132,10 @@ export function makeRecentRepositoriesGroup( for (const id of recentRepositories) { const repository = repositories.find(r => r.id === id) if (repository !== undefined) { - const existingCount = names.get(repository.name) || 0 - names.set(repository.name, existingCount + 1) + const alias = repository instanceof Repository ? repository.alias : null + const name = alias ?? repository.name + const existingCount = names.get(name) || 0 + names.set(name, existingCount + 1) } } @@ -147,11 +149,13 @@ export function makeRecentRepositoriesGroup( const { aheadBehind, changedFilesCount } = localRepositoryStateLookup.get(id) || fallbackValue + const repositoryAlias = + repository instanceof Repository ? repository.alias : null const repositoryText = repository instanceof Repository - ? [repository.name, nameOf(repository)] + ? [repositoryAlias ?? repository.name, nameOf(repository)] : [repository.name] - const nameCount = names.get(repository.name) || 0 + const nameCount = names.get(repositoryAlias ?? repository.name) || 0 items.push({ text: repositoryText, id: id.toString(), diff --git a/app/src/ui/repositories-list/repositories-list.tsx b/app/src/ui/repositories-list/repositories-list.tsx index cc3c5e8f75d..0d00b8411fe 100644 --- a/app/src/ui/repositories-list/repositories-list.tsx +++ b/app/src/ui/repositories-list/repositories-list.tsx @@ -11,7 +11,7 @@ import { } from './group-repositories' import { FilterList, IFilterListGroup } from '../lib/filter-list' import { IMatches } from '../../lib/fuzzy-find' -import { ILocalRepositoryState } from '../../models/repository' +import { ILocalRepositoryState, Repository } from '../../models/repository' import { Dispatcher } from '../dispatcher' import { Button } from '../lib/button' import { Octicon, OcticonSymbol } from '../octicons' @@ -138,6 +138,8 @@ export class RepositoriesList extends React.Component< onShowRepository={this.props.onShowRepository} onOpenInShell={this.props.onOpenInShell} onOpenInExternalEditor={this.props.onOpenInExternalEditor} + onChangeRepositoryAlias={this.onChangeRepositoryAlias} + onRemoveRepositoryAlias={this.onRemoveRepositoryAlias} externalEditorLabel={this.props.externalEditorLabel} shellLabel={this.props.shellLabel} matches={matches} @@ -320,4 +322,15 @@ export class RepositoriesList extends React.Component< private onCreateNewRepository = () => { this.props.dispatcher.showPopup({ type: PopupType.CreateRepository }) } + + private onChangeRepositoryAlias = (repository: Repository) => { + this.props.dispatcher.showPopup({ + type: PopupType.ChangeRepositoryAlias, + repository, + }) + } + + private onRemoveRepositoryAlias = (repository: Repository) => { + this.props.dispatcher.changeRepositoryAlias(repository, null) + } } diff --git a/app/src/ui/repositories-list/repository-list-item.tsx b/app/src/ui/repositories-list/repository-list-item.tsx index d4fed1996e9..a6645483427 100644 --- a/app/src/ui/repositories-list/repository-list-item.tsx +++ b/app/src/ui/repositories-list/repository-list-item.tsx @@ -11,6 +11,7 @@ import { RevealInFileManagerLabel, DefaultEditorLabel, } from '../lib/context-menu' +import { enableRepositoryAliases } from '../../lib/feature-flag' interface IRepositoryListItemProps { readonly repository: Repositoryish @@ -30,6 +31,12 @@ interface IRepositoryListItemProps { /** Called when the repository should be opened in an external editor */ readonly onOpenInExternalEditor: (repository: Repositoryish) => void + /** Called when the repository alias should be changed */ + readonly onChangeRepositoryAlias: (repository: Repository) => void + + /** Called when the repository alias should be removed */ + readonly onRemoveRepositoryAlias: (repository: Repository) => void + /** The current external editor selected by the user */ readonly externalEditorLabel?: string @@ -65,6 +72,9 @@ export class RepositoryListItem extends React.Component< ? gitHubRepo.fullName + '\n' + gitHubRepo.htmlURL + '\n' + path : path + const alias: string | null = + repository instanceof Repository ? repository.alias : null + let prefix: string | null = null if (this.props.needsDisambiguation && gitHubRepo) { prefix = `${gitHubRepo.owner.login}/` @@ -80,10 +90,11 @@ export class RepositoryListItem extends React.Component< className="icon-for-repository" symbol={iconForRepository(repository)} /> +
{prefix ? {prefix} : null}
@@ -121,6 +132,7 @@ export class RepositoryListItem extends React.Component< : DefaultEditorLabel const items: ReadonlyArray = [ + ...this.buildAliasMenuItems(), { label: `Open in ${this.props.shellLabel}`, action: this.openInShell, @@ -144,9 +156,37 @@ export class RepositoryListItem extends React.Component< action: this.removeRepository, }, ] + showContextualMenu(items) } + private buildAliasMenuItems(): ReadonlyArray { + const repository = this.props.repository + + if (!(repository instanceof Repository) || !enableRepositoryAliases()) { + return [] + } + + const verb = repository.alias == null ? 'Create' : 'Change' + const items: Array = [ + { + label: __DARWIN__ ? `${verb} Alias` : `${verb} alias`, + action: this.changeAlias, + }, + ] + + if (repository.alias !== null) { + items.push({ + label: __DARWIN__ ? 'Remove Alias' : 'Remove alias', + action: this.removeAlias, + }) + } + + items.push({ type: 'separator' }) + + return items + } + private removeRepository = () => { this.props.onRemoveRepository(this.props.repository) } @@ -162,6 +202,18 @@ export class RepositoryListItem extends React.Component< private openInExternalEditor = () => { this.props.onOpenInExternalEditor(this.props.repository) } + + private changeAlias = () => { + if (this.props.repository instanceof Repository) { + this.props.onChangeRepositoryAlias(this.props.repository) + } + } + + private removeAlias = () => { + if (this.props.repository instanceof Repository) { + this.props.onRemoveRepositoryAlias(this.props.repository) + } + } } const renderRepoIndicators: React.FunctionComponent<{ diff --git a/app/styles/ui/_dialog.scss b/app/styles/ui/_dialog.scss index 03e4197355d..10bcadab351 100644 --- a/app/styles/ui/_dialog.scss +++ b/app/styles/ui/_dialog.scss @@ -375,7 +375,8 @@ dialog { width: 400px; } - &#confirm-remove-repository { + &#confirm-remove-repository, + &#change-repository-alias { width: 450px; .description {