diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 9b50d3f7ecc..1d039824300 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -196,6 +196,9 @@ export interface IAppState { /** The width of the resizable branch drop down button in the toolbar. */ readonly branchDropdownWidth: IConstrainedValue + /** The width of the resizable worktree drop down button in the toolbar. */ + readonly worktreeDropdownWidth: IConstrainedValue + /** The width of the resizable push/pull button in the toolbar. */ readonly pushPullButtonWidth: IConstrainedValue @@ -310,6 +313,9 @@ export interface IAppState { /** Whether or not recent repositories should be shown in the repo list */ readonly showRecentRepositories: boolean + /** Whether or not the worktrees dropdown should be shown in the toolbar */ + readonly showWorktrees: boolean + /** * A map keyed on a user account (GitHub.com or GitHub Enterprise) * containing an object with repositories that the authenticated @@ -411,6 +417,7 @@ export enum FoldoutType { AppMenu, AddMenu, PushPull, + Worktree, } export type AppMenuFoldout = { @@ -434,6 +441,7 @@ export type Foldout = | BranchFoldout | AppMenuFoldout | { type: FoldoutType.PushPull } + | { type: FoldoutType.Worktree } export enum RepositorySectionTab { Changes, diff --git a/app/src/lib/git/index.ts b/app/src/lib/git/index.ts index e5a0cfcb58b..07c30b45c4f 100644 --- a/app/src/lib/git/index.ts +++ b/app/src/lib/git/index.ts @@ -33,3 +33,4 @@ export * from './gitignore' export * from './rebase' export * from './format-patch' export * from './tag' +export * from './worktree' diff --git a/app/src/lib/git/worktree.ts b/app/src/lib/git/worktree.ts new file mode 100644 index 00000000000..2297ec55ebe --- /dev/null +++ b/app/src/lib/git/worktree.ts @@ -0,0 +1,155 @@ +import * as Path from 'path' +import * as Fs from 'fs' +import type { Repository } from '../../models/repository' +import type { WorktreeEntry, WorktreeType } from '../../models/worktree' +import { git } from './core' + +export function parseWorktreePorcelainOutput( + stdout: string +): ReadonlyArray { + if (stdout.trim().length === 0) { + return [] + } + + const blocks = stdout.trim().split('\n\n') + const entries: WorktreeEntry[] = [] + + for (let i = 0; i < blocks.length; i++) { + const lines = blocks[i].split('\n') + let path = '' + let head = '' + let branch: string | null = null + let isDetached = false + let isLocked = false + let isPrunable = false + + for (const line of lines) { + if (line.startsWith('worktree ')) { + path = line.substring('worktree '.length) + } else if (line.startsWith('HEAD ')) { + head = line.substring('HEAD '.length) + } else if (line.startsWith('branch ')) { + branch = line.substring('branch '.length) + } else if (line === 'detached') { + isDetached = true + } else if (line === 'locked' || line.startsWith('locked ')) { + isLocked = true + } else if (line === 'prunable' || line.startsWith('prunable ')) { + isPrunable = true + } + } + + const type: WorktreeType = i === 0 ? 'main' : 'linked' + entries.push({ path, head, branch, isDetached, type, isLocked, isPrunable }) + } + + return entries +} + +export async function listWorktrees( + repository: Repository +): Promise> { + const result = await git( + ['worktree', 'list', '--porcelain'], + repository.path, + 'listWorktrees' + ) + + return parseWorktreePorcelainOutput(result.stdout) +} + +export async function addWorktree( + repository: Repository, + path: string, + options: { + readonly branch?: string + readonly createBranch?: string + readonly detach?: boolean + readonly commitish?: string + } = {} +): Promise { + const args = ['worktree', 'add'] + + if (options.detach) { + args.push('--detach') + } + + if (options.createBranch) { + args.push('-b', options.createBranch) + } + + args.push(path) + + if (options.branch) { + args.push(options.branch) + } else if (options.commitish) { + args.push(options.commitish) + } + + await git(args, repository.path, 'addWorktree') +} + +export async function removeWorktree( + repository: Repository, + path: string, + force: boolean = false +): Promise { + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(path) + + await git(args, repository.path, 'removeWorktree') +} + +export async function moveWorktree( + repository: Repository, + oldPath: string, + newPath: string +): Promise { + await git( + ['worktree', 'move', oldPath, newPath], + repository.path, + 'moveWorktree' + ) +} + +export async function isLinkedWorktree( + repository: Repository +): Promise { + const worktrees = await listWorktrees(repository) + const repoPath = repository.path + + return worktrees.some( + wt => + wt.type === 'linked' && normalizePath(wt.path) === normalizePath(repoPath) + ) +} + +export async function getMainWorktreePath( + repository: Repository +): Promise { + const worktrees = await listWorktrees(repository) + const main = worktrees.find(wt => wt.type === 'main') + 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 { + try { + const dotGit = Path.join(repositoryPath, '.git') + // eslint-disable-next-line no-sync + const stats = Fs.statSync(dotGit) + return stats.isFile() + } catch { + return false + } +} + +function normalizePath(p: string): string { + return p.replace(/\/+$/, '') +} diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index ac4fe444ba4..4f0fc4d4bde 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -390,6 +390,9 @@ const pullRequestFileListConfigKey: string = 'pull-request-files-width' const defaultBranchDropdownWidth: number = 230 const branchDropdownWidthConfigKey: string = 'branch-dropdown-width' +const defaultWorktreeDropdownWidth: number = 230 +const worktreeDropdownWidthConfigKey: string = 'worktree-dropdown-width' + const defaultPushPullButtonWidth: number = 230 const pushPullButtonWidthConfigKey: string = 'push-pull-button-width' @@ -442,6 +445,7 @@ const tabSizeKey: string = 'tab-size' const shellKey = 'shell' const showRecentRepositoriesKey = 'show-recent-repositories' +const showWorktreesKey = 'show-worktrees' const repositoryIndicatorsEnabledKey = 'enable-repository-indicators' // background fetching should occur hourly when Desktop is active, but this @@ -539,6 +543,7 @@ export class AppStore extends TypedBaseStore { private stashedFilesWidth = constrain(defaultStashedFilesWidth) private pullRequestFileListWidth = constrain(defaultPullRequestFileListWidth) private branchDropdownWidth = constrain(defaultBranchDropdownWidth) + private worktreeDropdownWidth = constrain(defaultWorktreeDropdownWidth) private pushPullButtonWidth = constrain(defaultPushPullButtonWidth) private windowState: WindowState | null = null @@ -603,6 +608,7 @@ export class AppStore extends TypedBaseStore { private selectedTabSize = tabSizeDefault private titleBarStyle: TitleBarStyle = 'native' private showRecentRepositories: boolean = true + private showWorktrees: boolean = false private hideWindowOnQuit: boolean = false private useWindowsOpenSSH: boolean = false @@ -711,6 +717,7 @@ export class AppStore extends TypedBaseStore { getBoolean(repositoryIndicatorsEnabledKey) ?? true this.showRecentRepositories = getBoolean(showRecentRepositoriesKey) ?? true + this.showWorktrees = getBoolean(showWorktreesKey) ?? false this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater( this.getRepositoriesForIndicatorRefresh, @@ -1104,6 +1111,7 @@ export class AppStore extends TypedBaseStore { emoji: this.emoji, sidebarWidth: this.sidebarWidth, branchDropdownWidth: this.branchDropdownWidth, + worktreeDropdownWidth: this.worktreeDropdownWidth, pushPullButtonWidth: this.pushPullButtonWidth, commitSummaryWidth: this.commitSummaryWidth, stashedFilesWidth: this.stashedFilesWidth, @@ -1146,6 +1154,7 @@ export class AppStore extends TypedBaseStore { selectedTabSize: this.selectedTabSize, titleBarStyle: this.titleBarStyle, showRecentRepositories: this.showRecentRepositories, + showWorktrees: this.showWorktrees, apiRepositories: this.apiRepositoriesStore.getState(), useWindowsOpenSSH: this.useWindowsOpenSSH, showCommitLengthWarning: this.showCommitLengthWarning, @@ -2373,6 +2382,9 @@ export class AppStore extends TypedBaseStore { this.branchDropdownWidth = constrain( getNumber(branchDropdownWidthConfigKey, defaultBranchDropdownWidth) ) + this.worktreeDropdownWidth = constrain( + getNumber(worktreeDropdownWidthConfigKey, defaultWorktreeDropdownWidth) + ) this.pushPullButtonWidth = constrain( getNumber(pushPullButtonWidthConfigKey, defaultPushPullButtonWidth) ) @@ -2564,12 +2576,13 @@ export class AppStore extends TypedBaseStore { * dimensions change. */ private updateResizableConstraints() { - // The combined width of the branch dropdown and the push/pull/fetch button + // The combined width of the toolbar buttons (worktree, branch, push/pull). // Since the repository list toolbar button width is tied to the width of - // the sidebar we can't let it push the branch, and push/pull/fetch button - // off screen. + // the sidebar we can't let it push these buttons off screen. const toolbarButtonsMinWidth = - defaultPushPullButtonWidth + defaultBranchDropdownWidth + defaultPushPullButtonWidth + + defaultBranchDropdownWidth + + defaultWorktreeDropdownWidth // Start with all the available width let available = window.innerWidth @@ -2606,16 +2619,14 @@ export class AppStore extends TypedBaseStore { this.commitSummaryWidth = constrain(this.commitSummaryWidth, 100, filesMax) this.stashedFilesWidth = constrain(this.stashedFilesWidth, 100, filesMax) - // Update the maximum width available for the branch dropdown resizable. - // The branch dropdown can only be as wide as the available space after - // taking the sidebar and pull/push/fetch button widths. If the room - // available is less than the default width, we will split the difference - // between the branch dropdown and the push/pull/fetch button so they stay - // visible on the most zoomed view. - const branchDropdownMax = available - defaultPushPullButtonWidth + // Allocate branch first (highest priority), then worktree, then + // push-pull. Each subsequent allocation uses the clamped value of the + // previous to prevent the total from exceeding the available space. + const branchDropdownMax = + available - defaultWorktreeDropdownWidth - defaultPushPullButtonWidth const minimumBranchDropdownWidth = - defaultBranchDropdownWidth > available / 2 - ? available / 2 - 10 // 10 is to give a little bit of space to see the fetch dropdown button + defaultBranchDropdownWidth > available / 3 + ? available / 3 - 10 : defaultBranchDropdownWidth this.branchDropdownWidth = constrain( this.branchDropdownWidth, @@ -2623,10 +2634,25 @@ export class AppStore extends TypedBaseStore { branchDropdownMax ) - const pushPullButtonMaxWidth = available - this.branchDropdownWidth.value + const worktreeDropdownMax = + available - clamp(this.branchDropdownWidth) - defaultPushPullButtonWidth + const minimumWorktreeDropdownWidth = + defaultWorktreeDropdownWidth > available / 3 + ? available / 3 - 10 + : defaultWorktreeDropdownWidth + this.worktreeDropdownWidth = constrain( + this.worktreeDropdownWidth, + minimumWorktreeDropdownWidth, + worktreeDropdownMax + ) + + const pushPullButtonMaxWidth = + available - + clamp(this.branchDropdownWidth) - + clamp(this.worktreeDropdownWidth) const minimumPushPullToolBarWidth = - defaultPushPullButtonWidth > available / 2 - ? available / 2 + 30 // 30 to clip the fetch dropdown button in favor of seeing more of the words on the toolbar buttons + defaultPushPullButtonWidth > available / 3 + ? available / 3 : defaultPushPullButtonWidth this.pushPullButtonWidth = constrain( this.pushPullButtonWidth, @@ -4022,6 +4048,15 @@ export class AppStore extends TypedBaseStore { this.emitUpdate() } + public _setShowWorktrees(showWorktrees: boolean) { + if (this.showWorktrees === showWorktrees) { + return + } + setBoolean(showWorktreesKey, showWorktrees) + this.showWorktrees = showWorktrees + this.emitUpdate() + } + public _setCommitSpellcheckEnabled(commitSpellcheckEnabled: boolean) { if (this.commitSpellcheckEnabled === commitSpellcheckEnabled) { return @@ -5763,6 +5798,30 @@ export class AppStore extends TypedBaseStore { return Promise.resolve() } + public _setWorktreeDropdownWidth(width: number): Promise { + this.worktreeDropdownWidth = { + ...this.worktreeDropdownWidth, + value: width, + } + setNumber(worktreeDropdownWidthConfigKey, width) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + + public _resetWorktreeDropdownWidth(): Promise { + this.worktreeDropdownWidth = { + ...this.worktreeDropdownWidth, + value: defaultWorktreeDropdownWidth, + } + localStorage.removeItem(worktreeDropdownWidthConfigKey) + this.updateResizableConstraints() + this.emitUpdate() + + return Promise.resolve() + } + public _setPushPullButtonWidth(width: number): Promise { this.pushPullButtonWidth = { ...this.pushPullButtonWidth, value: width } setNumber(pushPullButtonWidthConfigKey, width) diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index f2a12263b59..167bba41afd 100644 --- a/app/src/main-process/menu/build-default-menu.ts +++ b/app/src/main-process/menu/build-default-menu.ts @@ -208,6 +208,12 @@ export function buildDefaultMenu({ accelerator: 'CmdOrCtrl+B', click: emit('show-branches'), }, + { + label: __DARWIN__ ? 'Show Worktrees List' : '&Worktrees list', + id: 'show-worktrees-list', + accelerator: 'CmdOrCtrl+E', + click: emit('show-worktrees'), + }, separator, { label: __DARWIN__ ? 'Go to Summary' : 'Go to &Summary', @@ -625,7 +631,7 @@ function getStashedChangesLabel(isStashedChangesVisible: boolean): string { return __DARWIN__ ? 'Hide Stashed Changes' : 'H&ide stashed changes' } - return __DARWIN__ ? 'Show Stashed Changes' : 'Sho&w stashed changes' + return __DARWIN__ ? 'Show Stashed Changes' : 'Show stashed changes' } type ClickHandler = ( diff --git a/app/src/main-process/menu/menu-event.ts b/app/src/main-process/menu/menu-event.ts index 94a4814f9d7..8f8a166bfd7 100644 --- a/app/src/main-process/menu/menu-event.ts +++ b/app/src/main-process/menu/menu-event.ts @@ -9,6 +9,7 @@ export type MenuEvent = | 'add-local-repository' | 'create-branch' | 'show-branches' + | 'show-worktrees' | 'remove-repository' | 'create-repository' | 'rename-branch' diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index f75dcf0b426..5ff941f61f9 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -109,6 +109,9 @@ export enum PopupType { GenerateCommitMessageDisclaimer = 'GenerateCommitMessageDisclaimer', HookFailed = 'HookFailed', CommitProgress = 'CommitProgress', + AddWorktree = 'AddWorktree', + RenameWorktree = 'RenameWorktree', + DeleteWorktree = 'DeleteWorktree', } interface IBasePopup { @@ -484,4 +487,18 @@ export type PopupDetail = type: PopupType.CommitProgress subscribeToCommitOutput: TerminalOutputListener } + | { + type: PopupType.AddWorktree + repository: Repository + } + | { + type: PopupType.RenameWorktree + repository: Repository + worktreePath: string + } + | { + type: PopupType.DeleteWorktree + repository: Repository + worktreePath: string + } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/models/repository.ts b/app/src/models/repository.ts index 04b8ee5e4a3..0f7816c9b4d 100644 --- a/app/src/models/repository.ts +++ b/app/src/models/repository.ts @@ -8,6 +8,7 @@ import { } from './workflow-preferences' import { assertNever, fatalError } from '../lib/fatal-error' import { createEqualityHash } from './equality-hash' +import { isLinkedWorktreeSync } from '../lib/git/worktree' import { getRemotes } from '../lib/git' import { findDefaultRemote } from '../lib/stores/helpers/find-default-remote' import { isTrustedRemoteHost } from '../lib/api' @@ -51,6 +52,8 @@ export class Repository { */ private _url: string | null = null + private _isLinkedWorktree: boolean | undefined = undefined + /** * @param path The working directory of this repository * @param missing Was the repository missing on disk last we checked? @@ -93,6 +96,13 @@ export class Repository { return this.mainWorkTree.path } + public get isLinkedWorktree(): boolean { + if (this._isLinkedWorktree === undefined) { + this._isLinkedWorktree = isLinkedWorktreeSync(this.path) + } + return this._isLinkedWorktree + } + public get url(): string | null { // Resolve the default remote URL if not yet done. if (this._url === null) { diff --git a/app/src/models/worktree.ts b/app/src/models/worktree.ts new file mode 100644 index 00000000000..5290cba441c --- /dev/null +++ b/app/src/models/worktree.ts @@ -0,0 +1,12 @@ +export type WorktreeType = 'main' | 'linked' + +export type WorktreeEntry = { + readonly path: string + readonly head: string + /** Full ref name (e.g. `refs/heads/main`), or `null` when HEAD is detached */ + readonly branch: string | null + readonly isDetached: boolean + readonly type: WorktreeType + readonly isLocked: boolean + readonly isPrunable: boolean +} diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 93339ec11f9..b54d64067c8 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -50,6 +50,7 @@ import { DropdownState, PushPullButton, BranchDropdown, + WorktreeDropdown, RevertProgress, } from './toolbar' import { iconForRepository, OcticonSymbol } from './octicons' @@ -196,6 +197,9 @@ import { } from './secret-scanning/bypass-push-protection-dialog' import { HookFailed } from './hook-failed/hook-failed' import { CommitProgress } from './commit-progress/commit-progress' +import { AddWorktreeDialog } from './worktrees/add-worktree-dialog' +import { RenameWorktreeDialog } from './worktrees/rename-worktree-dialog' +import { DeleteWorktreeDialog } from './worktrees/delete-worktree-dialog' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -445,6 +449,8 @@ export class App extends React.Component { return this.showCreateBranch() case 'show-branches': return this.showBranches() + case 'show-worktrees': + return this.showWorktrees() case 'remove-repository': return this.removeRepository(this.getRepository()) case 'create-repository': @@ -954,6 +960,22 @@ export class App extends React.Component { return this.props.dispatcher.showFoldout({ type: FoldoutType.Branch }) } + private showWorktrees() { + const state = this.state.selectedState + if (state == null || state.type !== SelectionType.Repository) { + return + } + + if ( + this.state.currentFoldout && + this.state.currentFoldout.type === FoldoutType.Worktree + ) { + return this.props.dispatcher.closeFoldout(FoldoutType.Worktree) + } + + return this.props.dispatcher.showFoldout({ type: FoldoutType.Worktree }) + } + private push(options?: { forceWithLease: boolean }) { const state = this.state.selectedState if (state == null || state.type !== SelectionType.Repository) { @@ -1610,6 +1632,7 @@ export class App extends React.Component { branchPresetScript={this.state.branchPresetScript} titleBarStyle={this.state.titleBarStyle} showRecentRepositories={this.state.showRecentRepositories} + showWorktrees={this.state.showWorktrees} repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled} hideWindowOnQuit={this.state.hideWindowOnQuit} onEditGlobalGitConfig={this.editGlobalGitConfig} @@ -2624,6 +2647,38 @@ export class App extends React.Component { /> ) } + case PopupType.AddWorktree: { + return ( + + ) + } + case PopupType.RenameWorktree: { + return ( + + ) + } + case PopupType.DeleteWorktree: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } @@ -2928,13 +2983,16 @@ 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) + ) return ( { } } + private onWorktreeDropdownStateChanged = (newState: DropdownState) => { + if (newState === 'open') { + this.props.dispatcher.showFoldout({ type: FoldoutType.Worktree }) + } else { + this.props.dispatcher.closeFoldout(FoldoutType.Worktree) + } + } + private renderBranchToolbarButton(): JSX.Element | null { const selection = this.state.selectedState @@ -3362,6 +3428,39 @@ export class App extends React.Component { ) } + private renderWorktreeToolbarButton(): JSX.Element | null { + const selection = this.state.selectedState + + if (selection == null || selection.type !== SelectionType.Repository) { + return null + } + + if (!this.state.showWorktrees) { + return null + } + + const currentFoldout = this.state.currentFoldout + + const isOpen = + currentFoldout !== null && currentFoldout.type === FoldoutType.Worktree + + const repository = selection.repository + + const enableFocusTrap = this.state.currentPopup === null + + return ( + + ) + } + // we currently only render one banner at a time private renderBanner(): JSX.Element | null { // The inset light title bar style without the toolbar @@ -3439,6 +3538,7 @@ export class App extends React.Component { {this.renderRepositoryToolbarButton()} {this.renderBranchToolbarButton()} + {this.renderWorktreeToolbarButton()} {this.renderPushPullToolbarButton()} ) diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 24a32f44861..990574e60a4 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -436,6 +436,16 @@ export class Dispatcher { return this.appStore._closeFoldout(foldout) } + /** Show the worktrees foldout */ + public showWorktreesFoldout(): Promise { + return this.showFoldout({ type: FoldoutType.Worktree }) + } + + /** Close the worktrees foldout */ + public closeWorktreesFoldout(): Promise { + return this.closeFoldout(FoldoutType.Worktree) + } + /** * Check for remote commits that could affect an rebase operation. * @@ -1040,6 +1050,14 @@ export class Dispatcher { return this.appStore._resetBranchDropdownWidth() } + public setWorktreeDropdownWidth(width: number): Promise { + return this.appStore._setWorktreeDropdownWidth(width) + } + + public resetWorktreeDropdownWidth(): Promise { + return this.appStore._resetWorktreeDropdownWidth() + } + /** * Set the width of the Push/Push toolbar button to the given value. * This affects the toolbar button and its dropdown element. @@ -2856,6 +2874,10 @@ export class Dispatcher { this.appStore._setShowRecentRepositories(showRecentRepositories) } + public setShowWorktrees(showWorktrees: boolean) { + this.appStore._setShowWorktrees(showWorktrees) + } + public setHideWindowOnQuit(hideWindowOnQuit: boolean) { this.appStore._setHideWindowOnQuit(hideWindowOnQuit) } diff --git a/app/src/ui/preferences/appearance.tsx b/app/src/ui/preferences/appearance.tsx index 0564f046a7f..dff3f590fad 100644 --- a/app/src/ui/preferences/appearance.tsx +++ b/app/src/ui/preferences/appearance.tsx @@ -22,6 +22,8 @@ interface IAppearanceProps { readonly onTitleBarStyleChanged: (titleBarStyle: TitleBarStyle) => void readonly showRecentRepositories: boolean readonly onShowRecentRepositoriesChanged: (show: boolean) => void + readonly showWorktrees: boolean + readonly onShowWorktreesChanged: (show: boolean) => void } interface IAppearanceState { @@ -29,6 +31,7 @@ interface IAppearanceState { readonly selectedTabSize: number readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean + readonly showWorktrees: boolean } function getTitleBarStyleDescription(titleBarStyle: TitleBarStyle): string { @@ -56,6 +59,7 @@ export class Appearance extends React.Component< selectedTabSize: props.selectedTabSize, titleBarStyle: props.titleBarStyle, showRecentRepositories: props.showRecentRepositories, + showWorktrees: props.showWorktrees, } if (!usePropTheme) { @@ -99,6 +103,14 @@ export class Appearance extends React.Component< this.props.onShowRecentRepositoriesChanged(show) } + private onShowWorktreesChanged = ( + event: React.FormEvent + ) => { + const show = event.currentTarget.checked + this.setState({ showWorktrees: show }) + this.props.onShowWorktreesChanged(show) + } + private onSelectedTabSizeChanged = ( event: React.FormEvent ) => { @@ -219,6 +231,22 @@ export class Appearance extends React.Component< ) } + private renderWorktreeVisibility() { + return ( +
+

{'Worktrees'}

+ + +
+ ) + } + private renderSelectedTabSize() { const availableTabSizes: number[] = [1, 2, 3, 4, 5, 6, 8, 10, 12] @@ -246,6 +274,7 @@ export class Appearance extends React.Component< {this.renderSelectedTheme()} {this.renderRepositoryList()} + {this.renderWorktreeVisibility()} {this.renderSelectedTabSize()} {this.renderTitleBarStyleDropdown()} diff --git a/app/src/ui/preferences/preferences.tsx b/app/src/ui/preferences/preferences.tsx index 9720785fee4..1ed9e665510 100644 --- a/app/src/ui/preferences/preferences.tsx +++ b/app/src/ui/preferences/preferences.tsx @@ -90,6 +90,7 @@ interface IPreferencesProps { readonly branchPresetScript: ICustomIntegration | null readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean + readonly showWorktrees: boolean readonly repositoryIndicatorsEnabled: boolean readonly hideWindowOnQuit: boolean readonly onEditGlobalGitConfig: () => void @@ -132,6 +133,7 @@ interface IPreferencesState { readonly selectedShell: Shell readonly titleBarStyle: TitleBarStyle readonly showRecentRepositories: boolean + readonly showWorktrees: boolean /** * If unable to save Git configuration values (name, email) * due to an existing configuration lock file this property @@ -214,6 +216,7 @@ export class Preferences extends React.Component< selectedShell: this.props.selectedShell, titleBarStyle: this.props.titleBarStyle, showRecentRepositories: this.props.showRecentRepositories, + showWorktrees: this.props.showWorktrees, repositoryIndicatorsEnabled: this.props.repositoryIndicatorsEnabled, hideWindowOnQuit: this.props.hideWindowOnQuit, initiallySelectedTheme: this.props.selectedTheme, @@ -544,6 +547,8 @@ export class Preferences extends React.Component< onShowRecentRepositoriesChanged={ this.onShowRecentRepositoriesChanged } + showWorktrees={this.state.showWorktrees} + onShowWorktreesChanged={this.onShowWorktreesChanged} /> ) break @@ -803,6 +808,10 @@ export class Preferences extends React.Component< this.setState({ showRecentRepositories }) } + private onShowWorktreesChanged = (showWorktrees: boolean) => { + this.setState({ showWorktrees }) + } + private renderFooter() { const hasDisabledError = this.state.disallowedCharactersMessage != null @@ -865,6 +874,10 @@ export class Preferences extends React.Component< dispatcher.setShowRecentRepositories(this.state.showRecentRepositories) } + if (this.state.showWorktrees !== this.props.showWorktrees) { + dispatcher.setShowWorktrees(this.state.showWorktrees) + } + if (this.state.hideWindowOnQuit !== this.props.hideWindowOnQuit) { dispatcher.setHideWindowOnQuit(this.state.hideWindowOnQuit) } diff --git a/app/src/ui/toolbar/index.tsx b/app/src/ui/toolbar/index.tsx index 7b7fd1f228c..1e9951fd34d 100644 --- a/app/src/ui/toolbar/index.tsx +++ b/app/src/ui/toolbar/index.tsx @@ -3,4 +3,5 @@ export * from './button' export * from './dropdown' export * from './push-pull-button' export * from './branch-dropdown' +export * from './worktree-dropdown' export { RevertProgress } from './revert-progress' diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx new file mode 100644 index 00000000000..d8f47f1e746 --- /dev/null +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -0,0 +1,233 @@ +import * as React from 'react' +import * as Path from 'path' +import { Dispatcher } from '../dispatcher' +import * as octicons from '../octicons/octicons.generated' +import { Repository } from '../../models/repository' +import { ToolbarDropdown, DropdownState } from './dropdown' +import { FoldoutType, IConstrainedValue } from '../../lib/app-state' +import { WorktreeEntry } from '../../models/worktree' +import { WorktreeList } from '../worktrees/worktree-list' +import { listWorktrees } from '../../lib/git/worktree' +import { CloningRepository } from '../../models/cloning-repository' +import { showContextualMenu } from '../../lib/menu-item' +import { generateWorktreeContextMenuItems } from '../worktrees/worktree-list-item-context-menu' +import { PopupType } from '../../models/popup' +import { Resizable } from '../resizable' +import { enableResizingToolbarButtons } from '../../lib/feature-flag' + +interface IWorktreeDropdownProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly isOpen: boolean + readonly onDropDownStateChanged: (state: DropdownState) => void + readonly enableFocusTrap: boolean + readonly repositories: ReadonlyArray + readonly worktreeDropdownWidth: IConstrainedValue +} + +interface IWorktreeDropdownState { + readonly worktrees: ReadonlyArray + readonly filterText: string + readonly worktreeAddedRepo: Repository | null +} + +export class WorktreeDropdown extends React.Component< + IWorktreeDropdownProps, + IWorktreeDropdownState +> { + public constructor(props: IWorktreeDropdownProps) { + super(props) + this.state = { + worktrees: [], + filterText: '', + worktreeAddedRepo: null, + } + } + + public componentDidUpdate(prevProps: IWorktreeDropdownProps) { + if (!prevProps.isOpen && this.props.isOpen) { + this.fetchWorktrees() + } + } + + private async fetchWorktrees() { + const { repository } = this.props + + try { + const worktrees = await listWorktrees(repository) + this.setState({ worktrees }) + } catch (e) { + log.error('Failed to fetch worktrees', e) + this.setState({ worktrees: [] }) + } + } + + private onWorktreeClick = async (worktree: WorktreeEntry) => { + const { dispatcher, repositories } = this.props + const worktreePath = normalizePath(worktree.path) + const previousWorktreeRepo = this.state.worktreeAddedRepo + + dispatcher.closeFoldout(FoldoutType.Worktree) + + const existingRepo = repositories.find( + r => r instanceof Repository && normalizePath(r.path) === worktreePath + ) + + if (existingRepo && existingRepo instanceof Repository) { + await dispatcher.selectRepository(existingRepo) + this.setState({ worktreeAddedRepo: null }) + } else { + const addedRepos = await dispatcher.addRepositories([worktree.path]) + + if (addedRepos.length > 0) { + await dispatcher.selectRepository(addedRepos[0]) + this.setState({ worktreeAddedRepo: addedRepos[0] }) + } + } + + if (previousWorktreeRepo) { + await dispatcher.removeRepository(previousWorktreeRepo, false) + dispatcher.closeFoldout(FoldoutType.Repository) + } + } + + // Intentional no-op: navigation happens on click, not selection change + private onWorktreeSelected = (_worktree: WorktreeEntry) => {} + + private onWorktreeContextMenu = ( + worktree: WorktreeEntry, + event: React.MouseEvent + ) => { + event.preventDefault() + + const items = generateWorktreeContextMenuItems({ + path: worktree.path, + isMainWorktree: worktree.type === 'main', + isLocked: worktree.isLocked, + onRenameWorktree: this.onRenameWorktree, + onRemoveWorktree: this.onRemoveWorktree, + }) + + showContextualMenu(items) + } + + private onRenameWorktree = (path: string) => { + this.props.dispatcher.closeFoldout(FoldoutType.Worktree) + this.props.dispatcher.showPopup({ + type: PopupType.RenameWorktree, + repository: this.props.repository, + worktreePath: path, + }) + } + + private onRemoveWorktree = (path: string) => { + this.props.dispatcher.closeFoldout(FoldoutType.Worktree) + this.props.dispatcher.showPopup({ + type: PopupType.DeleteWorktree, + repository: this.props.repository, + worktreePath: path, + }) + } + + private onCreateNewWorktree = () => { + this.props.dispatcher.closeFoldout(FoldoutType.Worktree) + this.props.dispatcher.showPopup({ + type: PopupType.AddWorktree, + repository: this.props.repository, + }) + } + + private onFilterTextChanged = (text: string) => { + this.setState({ filterText: text }) + } + + private renderWorktreeFoldout = (): JSX.Element | null => { + const { worktrees } = this.state + + return ( + + ) + } + + private getCurrentWorktree(): WorktreeEntry | null { + const repoPath = normalizePath(this.props.repository.path) + return ( + this.state.worktrees.find(wt => normalizePath(wt.path) === repoPath) ?? + null + ) + } + + private onResize = (width: number) => { + this.props.dispatcher.setWorktreeDropdownWidth(width) + } + + private onReset = () => { + this.props.dispatcher.resetWorktreeDropdownWidth() + } + + public render() { + const { isOpen, enableFocusTrap } = this.props + const currentState: DropdownState = isOpen ? 'open' : 'closed' + const currentWorktree = this.getCurrentWorktree() + const title = currentWorktree + ? Path.basename(currentWorktree.path) + : this.props.repository.name + const description = __DARWIN__ ? 'Current Worktree' : 'Current worktree' + + const toolbarDropdown = ( + + ) + + if (!enableResizingToolbarButtons()) { + return toolbarDropdown + } + + return ( + + {toolbarDropdown} + + ) + } +} + +function normalizePath(p: string): string { + return p.replace(/\/+$/, '') +} diff --git a/app/src/ui/worktrees/add-worktree-dialog.tsx b/app/src/ui/worktrees/add-worktree-dialog.tsx new file mode 100644 index 00000000000..96b1d50abef --- /dev/null +++ b/app/src/ui/worktrees/add-worktree-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { TextBox } from '../lib/text-box' +import { RefNameTextBox } from '../lib/ref-name-text-box' +import { Button } from '../lib/button' +import { Row } from '../lib/row' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { showOpenDialog } from '../main-process-proxy' +import { addWorktree } from '../../lib/git/worktree' + +interface IAddWorktreeDialogProps { + readonly repository: Repository + readonly dispatcher: Dispatcher + readonly onDismissed: () => void +} + +interface IAddWorktreeDialogState { + readonly parentDirPath: string + readonly branchName: string + readonly creating: boolean +} + +export class AddWorktreeDialog extends React.Component< + IAddWorktreeDialogProps, + IAddWorktreeDialogState +> { + private branchNameTextBoxRef = React.createRef() + + public constructor(props: IAddWorktreeDialogProps) { + super(props) + + this.state = { + parentDirPath: Path.dirname(props.repository.path), + branchName: '', + creating: false, + } + } + + public componentDidMount() { + this.branchNameTextBoxRef.current?.focus() + } + + private onParentDirPathChanged = (parentDirPath: string) => { + this.setState({ parentDirPath }) + } + + private onBranchNameChanged = (branchName: string) => { + this.setState({ branchName }) + } + + private showFilePicker = async () => { + const path = await showOpenDialog({ + properties: ['createDirectory', 'openDirectory'], + }) + + if (path === null) { + return + } + + this.setState({ parentDirPath: path }) + } + + private onSubmit = async () => { + const { parentDirPath: path, branchName } = this.state + const { dispatcher } = this.props + + this.setState({ creating: true }) + const worktreePath = Path.join(path, branchName) + + try { + await addWorktree(this.props.repository, worktreePath, { + createBranch: branchName.length > 0 ? branchName : undefined, + }) + } catch (e) { + dispatcher.postError(e) + this.setState({ creating: false }) + return + } + + const addedRepos = await dispatcher.addRepositories([worktreePath]) + + if (addedRepos.length > 0) { + await dispatcher.selectRepository(addedRepos[0]) + } + + this.setState({ creating: false }) + this.props.onDismissed() + } + + public render() { + const disabled = + !this.state.parentDirPath || + !this.state.branchName || + this.state.creating || + !Path.isAbsolute(this.state.parentDirPath) + + return ( + + + + + + + + + + + + + + + + + ) + } +} diff --git a/app/src/ui/worktrees/delete-worktree-dialog.tsx b/app/src/ui/worktrees/delete-worktree-dialog.tsx new file mode 100644 index 00000000000..d7b2e7c0986 --- /dev/null +++ b/app/src/ui/worktrees/delete-worktree-dialog.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { Ref } from '../lib/ref' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { removeWorktree, getMainWorktreePath } from '../../lib/git/worktree' + +interface IDeleteWorktreeDialogProps { + readonly repository: Repository + readonly worktreePath: string + readonly dispatcher: Dispatcher + readonly onDismissed: () => void +} + +interface IDeleteWorktreeDialogState { + readonly isDeleting: boolean +} + +export class DeleteWorktreeDialog extends React.Component< + IDeleteWorktreeDialogProps, + IDeleteWorktreeDialogState +> { + public constructor(props: IDeleteWorktreeDialogProps) { + super(props) + + this.state = { + isDeleting: false, + } + } + + public render() { + const name = Path.basename(this.props.worktreePath) + + return ( + + +

+ Are you sure you want to delete the worktree {name}? +

+
+ + + +
+ ) + } + + private onDeleteWorktree = async () => { + this.setState({ isDeleting: true }) + + const { repository, worktreePath, dispatcher } = this.props + const isDeletingCurrentWorktree = + normalizePath(repository.path) === normalizePath(worktreePath) + + try { + if (isDeletingCurrentWorktree) { + // When deleting the currently selected worktree, we must switch away + // first. Otherwise git runs from the directory being deleted and the + // app is left pointing at a non-existent path. + const mainPath = await getMainWorktreePath(repository) + if (mainPath === null) { + throw new Error('Could not find main worktree') + } + + const addedRepos = await dispatcher.addRepositories([mainPath]) + if (addedRepos.length === 0) { + throw new Error('Could not add main worktree repository') + } + + const mainRepo = addedRepos[0] + await dispatcher.selectRepository(mainRepo) + await removeWorktree(mainRepo, worktreePath) + await dispatcher.removeRepository(repository, false) + } else { + await removeWorktree(repository, worktreePath) + } + } catch (e) { + dispatcher.postError(e) + this.setState({ isDeleting: false }) + return + } + + this.props.onDismissed() + } +} + +function normalizePath(p: string): string { + return p.replace(/\/+$/, '') +} diff --git a/app/src/ui/worktrees/rename-worktree-dialog.tsx b/app/src/ui/worktrees/rename-worktree-dialog.tsx new file mode 100644 index 00000000000..eefc84c5bc3 --- /dev/null +++ b/app/src/ui/worktrees/rename-worktree-dialog.tsx @@ -0,0 +1,91 @@ +import * as React from 'react' +import * as Path from 'path' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { TextBox } from '../lib/text-box' +import { OkCancelButtonGroup } from '../dialog/ok-cancel-button-group' +import { moveWorktree } from '../../lib/git/worktree' + +interface IRenameWorktreeDialogProps { + readonly repository: Repository + readonly worktreePath: string + readonly dispatcher: Dispatcher + readonly onDismissed: () => void +} + +interface IRenameWorktreeDialogState { + readonly newName: string + readonly renaming: boolean +} + +export class RenameWorktreeDialog extends React.Component< + IRenameWorktreeDialogProps, + IRenameWorktreeDialogState +> { + public constructor(props: IRenameWorktreeDialogProps) { + super(props) + + this.state = { + newName: Path.basename(props.worktreePath), + renaming: false, + } + } + + private onNameChanged = (newName: string) => { + this.setState({ newName }) + } + + private onSubmit = async () => { + const { worktreePath, repository, onDismissed } = this.props + const { newName } = this.state + const newPath = Path.join(Path.dirname(worktreePath), newName) + + this.setState({ renaming: true }) + + try { + await moveWorktree(repository, worktreePath, newPath) + } catch (e) { + this.props.dispatcher.postError(e) + this.setState({ renaming: false }) + return + } + + this.setState({ renaming: false }) + onDismissed() + } + + public render() { + const currentName = Path.basename(this.props.worktreePath) + const disabled = + this.state.newName.length === 0 || + this.state.newName === currentName || + this.state.renaming + + return ( + + + + + + + + + + ) + } +} diff --git a/app/src/ui/worktrees/worktree-list-item-context-menu.ts b/app/src/ui/worktrees/worktree-list-item-context-menu.ts new file mode 100644 index 00000000000..dc837466871 --- /dev/null +++ b/app/src/ui/worktrees/worktree-list-item-context-menu.ts @@ -0,0 +1,51 @@ +import * as Path from 'path' + +import { IMenuItem } from '../../lib/menu-item' +import { clipboard } from 'electron' + +interface IWorktreeContextMenuConfig { + readonly path: string + readonly isMainWorktree: boolean + readonly isLocked: boolean + readonly onRenameWorktree?: (path: string) => void + readonly onRemoveWorktree?: (path: string) => void +} + +export function generateWorktreeContextMenuItems( + config: IWorktreeContextMenuConfig +): ReadonlyArray { + const { path, isMainWorktree, isLocked, onRenameWorktree, onRemoveWorktree } = + config + const name = Path.basename(path) + const items = new Array() + + if (onRenameWorktree !== undefined) { + items.push({ + label: 'Rename…', + action: () => onRenameWorktree(path), + enabled: !isMainWorktree && !isLocked, + }) + } + + items.push({ + label: __DARWIN__ ? 'Copy Worktree Name' : 'Copy worktree name', + action: () => clipboard.writeText(name), + }) + + items.push({ + label: __DARWIN__ ? 'Copy Worktree Path' : 'Copy worktree path', + action: () => clipboard.writeText(path), + }) + + items.push({ type: 'separator' }) + + if (onRemoveWorktree !== undefined) { + items.push({ + label: 'Delete…', + action: () => onRemoveWorktree(path), + enabled: !isMainWorktree && !isLocked, + }) + } + + return items +} diff --git a/app/src/ui/worktrees/worktree-list-item.tsx b/app/src/ui/worktrees/worktree-list-item.tsx new file mode 100644 index 00000000000..121c93f0a47 --- /dev/null +++ b/app/src/ui/worktrees/worktree-list-item.tsx @@ -0,0 +1,53 @@ +import * as React from 'react' +import * as Path from 'path' +import { WorktreeEntry } from '../../models/worktree' +import { IMatches } from '../../lib/fuzzy-find' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { HighlightText } from '../lib/highlight-text' +import classNames from 'classnames' +import { TooltippedContent } from '../lib/tooltipped-content' +import { enableAccessibleListToolTips } from '../../lib/feature-flag' + +interface IWorktreeListItemProps { + readonly worktree: WorktreeEntry + readonly isCurrentWorktree: boolean + readonly matches: IMatches +} + +export class WorktreeListItem extends React.Component { + public render() { + const { worktree, isCurrentWorktree, matches } = this.props + const name = Path.basename(worktree.path) + const icon = isCurrentWorktree ? octicons.check : octicons.fileDirectory + const className = classNames('worktrees-list-item', { + 'current-worktree': isCurrentWorktree, + }) + + return ( +
+ + + + + {worktree.branch && ( + + {worktree.branch} + + )} +
+ ) + } +} diff --git a/app/src/ui/worktrees/worktree-list.tsx b/app/src/ui/worktrees/worktree-list.tsx new file mode 100644 index 00000000000..90bcb6682f0 --- /dev/null +++ b/app/src/ui/worktrees/worktree-list.tsx @@ -0,0 +1,164 @@ +import * as React from 'react' +import * as Path from 'path' +import { WorktreeEntry } from '../../models/worktree' +import { IFilterListGroup, IFilterListItem } from '../lib/filter-list' +import { SectionFilterList } from '../lib/section-filter-list' +import { WorktreeListItem } from './worktree-list-item' +import { Button } from '../lib/button' +import { Octicon } from '../octicons' +import * as octicons from '../octicons/octicons.generated' +import { IMatches } from '../../lib/fuzzy-find' +import { ClickSource } from '../lib/list' +import memoizeOne from 'memoize-one' + +const RowHeight = 30 + +interface IWorktreeListItem extends IFilterListItem { + readonly text: ReadonlyArray + readonly id: string + readonly worktree: WorktreeEntry +} + +interface IWorktreeListProps { + readonly worktrees: ReadonlyArray + readonly currentWorktree: WorktreeEntry | null + readonly selectedWorktree: WorktreeEntry | null + readonly onWorktreeSelected: (worktree: WorktreeEntry) => void + readonly onWorktreeClick?: ( + worktree: WorktreeEntry, + source: ClickSource + ) => void + readonly onFilterTextChanged: (text: string) => void + readonly filterText: string + readonly canCreateNewWorktree: boolean + readonly onCreateNewWorktree?: () => void + readonly onWorktreeContextMenu?: ( + worktree: WorktreeEntry, + event: React.MouseEvent + ) => void +} + +type WorktreeGroupIdentifier = 'main' | 'linked' + +export class WorktreeList extends React.Component { + private getGroups = memoizeOne((worktrees: ReadonlyArray) => { + const groups: Array< + IFilterListGroup + > = [] + + const mainWorktree = worktrees.find(w => w.type === 'main') + const linkedWorktrees = worktrees.filter(w => w.type === 'linked') + + if (mainWorktree) { + groups.push({ + identifier: 'main', + items: [ + { + text: [Path.basename(mainWorktree.path)], + id: mainWorktree.path, + worktree: mainWorktree, + }, + ], + }) + } + + if (linkedWorktrees.length > 0) { + groups.push({ + identifier: 'linked', + items: linkedWorktrees.map(w => ({ + text: [Path.basename(w.path)], + id: w.path, + worktree: w, + })), + }) + } + + return groups + }) + + private renderItem = (item: IWorktreeListItem, matches: IMatches) => { + return ( + + ) + } + + private renderGroupHeader = (identifier: WorktreeGroupIdentifier) => { + const label = identifier === 'main' ? 'Main Worktree' : 'Linked Worktrees' + return
{label}
+ } + + private onRenderNewButton = () => { + if (!this.props.canCreateNewWorktree || !this.props.onCreateNewWorktree) { + return null + } + return ( + + ) + } + + private onRenderNoItems = () => { + return
No worktrees found
+ } + + private onItemClick = (item: IWorktreeListItem, source: ClickSource) => { + if (this.props.onWorktreeClick) { + this.props.onWorktreeClick(item.worktree, source) + } + } + + private onSelectionChanged = (item: IWorktreeListItem | null) => { + if (item) { + this.props.onWorktreeSelected(item.worktree) + } + } + + private onItemContextMenu = ( + item: IWorktreeListItem, + event: React.MouseEvent + ) => { + if (this.props.onWorktreeContextMenu) { + this.props.onWorktreeContextMenu(item.worktree, event) + } + } + + public render() { + const groups = this.getGroups(this.props.worktrees) + const selectedItem = + groups + .flatMap(g => g.items) + .find(i => i.worktree.path === this.props.selectedWorktree?.path) || + null + + return ( + + className="worktree-list" + rowHeight={RowHeight} + filterText={this.props.filterText} + onFilterTextChanged={this.props.onFilterTextChanged} + selectedItem={selectedItem} + renderItem={this.renderItem} + renderGroupHeader={this.renderGroupHeader} + onItemClick={this.onItemClick} + onSelectionChanged={this.onSelectionChanged} + groups={groups} + invalidationProps={this.props.worktrees} + renderPostFilter={this.onRenderNewButton} + renderNoItems={this.onRenderNoItems} + onItemContextMenu={this.onItemContextMenu} + /> + ) + } +} diff --git a/app/styles/_ui.scss b/app/styles/_ui.scss index bc19e0569be..757f853d5b6 100644 --- a/app/styles/_ui.scss +++ b/app/styles/_ui.scss @@ -22,11 +22,13 @@ @import 'ui/toolbar/button'; @import 'ui/toolbar/dropdown'; @import 'ui/toolbar/push-pull-button'; +@import 'ui/toolbar/worktree-dropdown'; @import 'ui/tab-bar'; @import 'ui/panel'; @import 'ui/popup'; @import 'ui/progress'; @import 'ui/branches'; +@import 'ui/worktrees'; @import 'ui/emoji'; @import 'ui/ui-view'; @import 'ui/autocompletion'; diff --git a/app/styles/ui/_ref-name-text-box.scss b/app/styles/ui/_ref-name-text-box.scss index 0adcb9c2865..7b035bf8841 100644 --- a/app/styles/ui/_ref-name-text-box.scss +++ b/app/styles/ui/_ref-name-text-box.scss @@ -1,4 +1,5 @@ .ref-name-text-box { + width: 100%; margin-bottom: var(--spacing); .warning-helper-text { diff --git a/app/styles/ui/_worktrees.scss b/app/styles/ui/_worktrees.scss new file mode 100644 index 00000000000..4490e1bf2a4 --- /dev/null +++ b/app/styles/ui/_worktrees.scss @@ -0,0 +1,71 @@ +@import '../mixins'; + +.worktree-list { + height: 100%; + display: flex; + flex-direction: column; + width: 100%; + min-height: 0; + + .worktrees-list-item { + height: 100%; + display: flex; + flex-direction: row; + align-items: center; + min-width: 0; + flex-grow: 1; + padding: 0 var(--spacing); + + .icon { + margin-right: var(--spacing-half); + width: 16px; // Force a consistent width + flex-shrink: 0; + color: var(--text-secondary-color); + } + + &.current-worktree .icon { + color: var(--text-color); + } + + .name { + flex-grow: 2; + @include ellipsis; + max-width: 65%; + margin-right: var(--spacing-half); + + /* Used to highlight substring matches in filtered lists */ + mark { + font-weight: bold; + /* Reset browser defaults */ + background-color: inherit; + color: currentColor; + } + } + + .description { + margin-right: var(--spacing-half); + color: var(--text-secondary-color); + font-size: var(--font-size-sm); + flex-grow: 1; + text-align: right; + white-space: nowrap; + @include ellipsis; + } + } + + .new-worktree-button { + flex-shrink: 0; + } + + .no-items-found { + padding: var(--spacing); + text-align: center; + color: var(--text-secondary-color); + } + + .filter-list-group-header { + @include ellipsis; + padding: 0 var(--spacing); + font-weight: var(--font-weight-semibold); + } +} diff --git a/app/styles/ui/toolbar/_worktree-dropdown.scss b/app/styles/ui/toolbar/_worktree-dropdown.scss new file mode 100644 index 00000000000..5048269a426 --- /dev/null +++ b/app/styles/ui/toolbar/_worktree-dropdown.scss @@ -0,0 +1,17 @@ +.worktree-button { + max-width: 250px; + + &.resizable { + max-width: none; + width: 100%; + } + + .toolbar-dropdown-button { + max-width: 100%; + } + + .title, + .description { + @include ellipsis; + } +} diff --git a/app/test/unit/git/worktree-test.ts b/app/test/unit/git/worktree-test.ts new file mode 100644 index 00000000000..cb1383ef8e0 --- /dev/null +++ b/app/test/unit/git/worktree-test.ts @@ -0,0 +1,164 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import { parseWorktreePorcelainOutput } from '../../../src/lib/git/worktree' + +describe('git/worktree', () => { + describe('parseWorktreePorcelainOutput', () => { + it('returns empty array for empty output', () => { + assert.deepStrictEqual(parseWorktreePorcelainOutput(''), []) + assert.deepStrictEqual(parseWorktreePorcelainOutput(' \n '), []) + }) + + it('parses a single main worktree', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries.length, 1) + assert.deepStrictEqual(entries[0], { + path: '/path/to/repo', + head: 'abc1234abc1234abc1234abc1234abc1234abc123', + branch: 'refs/heads/main', + isDetached: false, + type: 'main', + isLocked: false, + isPrunable: false, + }) + }) + + it('parses multiple worktrees', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/linked', + 'HEAD def5678def5678def5678def5678def5678def567', + 'branch refs/heads/feature', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries.length, 2) + + assert.strictEqual(entries[0].type, 'main') + assert.strictEqual(entries[0].path, '/path/to/repo') + + assert.strictEqual(entries[1].type, 'linked') + assert.strictEqual(entries[1].path, '/path/to/linked') + assert.strictEqual(entries[1].branch, 'refs/heads/feature') + }) + + it('parses detached HEAD worktree', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/detached', + 'HEAD def5678def5678def5678def5678def5678def567', + 'detached', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries.length, 2) + + assert.strictEqual(entries[1].isDetached, true) + assert.strictEqual(entries[1].branch, null) + }) + + it('parses locked worktree', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/locked-wt', + 'HEAD def5678def5678def5678def5678def5678def567', + 'branch refs/heads/locked-branch', + 'locked', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries[1].isLocked, true) + }) + + it('parses locked worktree with reason', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/locked-wt', + 'HEAD def5678def5678def5678def5678def5678def567', + 'branch refs/heads/locked-branch', + 'locked reason why it is locked', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries[1].isLocked, true) + }) + + it('parses prunable worktree', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/prunable-wt', + 'HEAD def5678def5678def5678def5678def5678def567', + 'branch refs/heads/stale', + 'prunable gitdir file points to non-existent location', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries[1].isPrunable, true) + }) + + it('parses paths with spaces', () => { + const output = [ + 'worktree /path/to/my repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/my other worktree', + 'HEAD def5678def5678def5678def5678def5678def567', + 'branch refs/heads/feature', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries[0].path, '/path/to/my repo') + assert.strictEqual(entries[1].path, '/path/to/my other worktree') + }) + + it('parses worktree with locked and prunable flags combined', () => { + const output = [ + 'worktree /path/to/repo', + 'HEAD abc1234abc1234abc1234abc1234abc1234abc123', + 'branch refs/heads/main', + '', + 'worktree /path/to/bad-wt', + 'HEAD def5678def5678def5678def5678def5678def567', + 'detached', + 'locked', + 'prunable', + '', + ].join('\n') + + const entries = parseWorktreePorcelainOutput(output) + assert.strictEqual(entries[1].isDetached, true) + assert.strictEqual(entries[1].isLocked, true) + assert.strictEqual(entries[1].isPrunable, true) + assert.strictEqual(entries[1].branch, null) + }) + }) +})