Skip to content

Commit 45161c8

Browse files
committed
feat(sidebar): show worktrees under their repository
Add an opt-in sidebar mode that lists linked worktrees beneath their main repository so repository switching can stay in the main sidebar instead of requiring the worktree dropdown. Changes: - add a secondary appearance setting to show worktrees in the repository sidebar when worktree support is enabled - group linked worktrees under their main repository and synthesize sidebar rows for worktrees that are not already stored as repositories - render nested rows with the worktree folder name, keep alias styling behavior unchanged, and avoid duplicate pull-all work for linked worktrees - persist sidebar worktree metadata in repository state and expose main worktree path helpers needed for grouping - add unit coverage for grouped and synthetic worktree rows and throttle sidebar worktree refreshes to reduce repeated git worktree list churn during indicator updates Testing: - yarn test:unit app/test/unit/repositories-list-grouping-test.ts - yarn eslint app/src/lib/stores/app-store.ts app/src/ui/repositories-list/group-repositories.ts app/src/ui/repositories-list/repositories-list.tsx app/src/ui/repositories-list/repository-list-item.tsx app/src/ui/preferences/appearance.tsx app/src/ui/preferences/preferences.tsx app/src/lib/git/worktree.ts app/src/models/repository.ts app/src/lib/app-state.ts app/src/ui/app.tsx app/src/ui/dispatcher/dispatcher.ts app/test/unit/repositories-list-grouping-test.ts - yarn compile:dev
1 parent a5b51cd commit 45161c8

13 files changed

Lines changed: 604 additions & 55 deletions

File tree

app/src/lib/app-state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,9 @@ export interface IAppState {
320320
/** Whether or not the worktrees dropdown should be shown in the toolbar */
321321
readonly showWorktrees: boolean
322322

323+
/** Whether linked worktrees should be shown under their repository in the sidebar */
324+
readonly showWorktreesInSidebar: boolean
325+
323326
/** Whether or not the Compare tab should be shown in the repository view */
324327
readonly showCompareTab: boolean
325328

app/src/lib/git/worktree.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,36 @@ import type { WorktreeEntry, WorktreeType } from '../../models/worktree'
55
import { git } from './core'
66
import { normalizePath } from '../helpers/path'
77

8+
function getDotGitPath(repositoryPath: string): string {
9+
return Path.join(repositoryPath, '.git')
10+
}
11+
12+
function getGitDirPathSync(repositoryPath: string): string | null {
13+
const dotGit = getDotGitPath(repositoryPath)
14+
15+
try {
16+
// eslint-disable-next-line no-sync
17+
const stats = Fs.statSync(dotGit)
18+
if (stats.isDirectory()) {
19+
return dotGit
20+
}
21+
22+
if (!stats.isFile()) {
23+
return null
24+
}
25+
26+
// eslint-disable-next-line no-sync
27+
const contents = Fs.readFileSync(dotGit, 'utf8').trim()
28+
if (!contents.startsWith('gitdir: ')) {
29+
return null
30+
}
31+
32+
return Path.resolve(repositoryPath, contents.substring('gitdir: '.length))
33+
} catch {
34+
return null
35+
}
36+
}
37+
838
export function parseWorktreePorcelainOutput(
939
stdout: string
1040
): ReadonlyArray<WorktreeEntry> {
@@ -141,11 +171,36 @@ export async function getMainWorktreePath(
141171
*/
142172
export function isLinkedWorktreeSync(repositoryPath: string): boolean {
143173
try {
144-
const dotGit = Path.join(repositoryPath, '.git')
174+
const dotGit = getDotGitPath(repositoryPath)
145175
// eslint-disable-next-line no-sync
146176
const stats = Fs.statSync(dotGit)
147177
return stats.isFile()
148178
} catch {
149179
return false
150180
}
151181
}
182+
183+
export function getMainWorktreePathSync(repositoryPath: string): string | null {
184+
const gitDirPath = getGitDirPathSync(repositoryPath)
185+
if (gitDirPath === null) {
186+
return null
187+
}
188+
189+
if (!isLinkedWorktreeSync(repositoryPath)) {
190+
return repositoryPath
191+
}
192+
193+
try {
194+
// eslint-disable-next-line no-sync
195+
const commondir = Fs.readFileSync(Path.join(gitDirPath, 'commondir'), 'utf8')
196+
.trim()
197+
if (commondir.length === 0) {
198+
return null
199+
}
200+
201+
const commonGitDir = Path.resolve(gitDirPath, commondir)
202+
return Path.dirname(commonGitDir)
203+
} catch {
204+
return null
205+
}
206+
}

app/src/lib/stores/app-store.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,10 +467,17 @@ const shellKey = 'shell'
467467

468468
const showRecentRepositoriesKey = 'show-recent-repositories'
469469
const showWorktreesKey = 'show-worktrees'
470+
const showWorktreesInSidebarKey = 'show-worktrees-in-sidebar'
470471
const showCompareTabKey = 'show-compare-tab'
471472
const showCompareTabDefault = true
472473
const repositoryIndicatorsEnabledKey = 'enable-repository-indicators'
473474

475+
/**
476+
* Refresh sidebar worktree metadata more sparingly than the repository
477+
* indicator loop to avoid repeatedly shelling out to `git worktree list`.
478+
*/
479+
const SidebarWorktreeRefreshInterval = 2 * 60 * 1000
480+
474481
// background fetching should occur hourly when Desktop is active, but this
475482
// lower interval ensures user interactions like switching repositories and
476483
// switching between apps does not result in excessive fetching in the app
@@ -636,6 +643,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
636643
private titleBarStyle: TitleBarStyle = 'native'
637644
private showRecentRepositories: boolean = true
638645
private showWorktrees: boolean = false
646+
private showWorktreesInSidebar: boolean = false
647+
private readonly lastSidebarWorktreeRefreshAt = new Map<string, number>()
639648
private showCompareTab: boolean = showCompareTabDefault
640649
private hideWindowOnQuit: boolean = __DARWIN__
641650

@@ -754,6 +763,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
754763

755764
this.showRecentRepositories = getBoolean(showRecentRepositoriesKey) ?? true
756765
this.showWorktrees = getBoolean(showWorktreesKey) ?? false
766+
this.showWorktreesInSidebar =
767+
this.showWorktrees &&
768+
(getBoolean(showWorktreesInSidebarKey) ?? false)
757769
this.showCompareTab = getBoolean(showCompareTabKey, showCompareTabDefault)
758770

759771
this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater(
@@ -1207,6 +1219,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
12071219
titleBarStyle: this.titleBarStyle,
12081220
showRecentRepositories: this.showRecentRepositories,
12091221
showWorktrees: this.showWorktrees,
1222+
showWorktreesInSidebar: this.showWorktreesInSidebar,
12101223
showCompareTab: this.showCompareTab,
12111224
apiRepositories: this.apiRepositoriesStore.getState(),
12121225
useWindowsOpenSSH: this.useWindowsOpenSSH,
@@ -4057,6 +4070,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
40574070
changedFilesCount: status.workingDirectory.files.length,
40584071
branchName: status.currentBranch || null,
40594072
defaultBranchName: repository.defaultBranch,
4073+
allWorktrees: this.showWorktreesInSidebar
4074+
? this.repositoryStateCache.get(repository).worktreesState.allWorktrees
4075+
: [],
40604076
})
40614077
}
40624078
/**
@@ -4083,6 +4099,19 @@ export class AppStore extends TypedBaseStore<IAppState> {
40834099
return
40844100
}
40854101

4102+
if (
4103+
this.showWorktreesInSidebar &&
4104+
!repository.isLinkedWorktree &&
4105+
this.shouldRefreshSidebarWorktrees(repository)
4106+
) {
4107+
await gitStore.loadWorktrees()
4108+
this.repositoryStateCache.updateWorktreesState(repository, () => ({
4109+
allWorktrees: gitStore.allWorktrees,
4110+
currentWorktree: gitStore.currentWorktree,
4111+
}))
4112+
this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now())
4113+
}
4114+
40864115
this.updateSidebarIndicator(repository, status)
40874116
this.emitUpdate()
40884117

@@ -4104,11 +4133,26 @@ export class AppStore extends TypedBaseStore<IAppState> {
41044133
changedFilesCount: existing?.changedFilesCount ?? 0,
41054134
branchName: existing?.branchName ?? null,
41064135
defaultBranchName: existing?.defaultBranchName ?? null,
4136+
allWorktrees: existing?.allWorktrees ?? [],
41074137
})
41084138
this.emitUpdate()
41094139
}
41104140
}
41114141

4142+
private shouldRefreshSidebarWorktrees(repository: Repository) {
4143+
const lastRefreshedAt =
4144+
this.lastSidebarWorktreeRefreshAt.get(repository.hash) ?? 0
4145+
4146+
if (Date.now() - lastRefreshedAt >= SidebarWorktreeRefreshInterval) {
4147+
return true
4148+
}
4149+
4150+
const cachedWorktrees =
4151+
this.repositoryStateCache.get(repository).worktreesState.allWorktrees
4152+
4153+
return cachedWorktrees.length === 0
4154+
}
4155+
41124156
private getRepositoriesForIndicatorRefresh = () => {
41134157
// The currently selected repository will get refreshed by both the
41144158
// BackgroundFetcher and the refreshRepository call from the
@@ -4178,10 +4222,30 @@ export class AppStore extends TypedBaseStore<IAppState> {
41784222
}
41794223
setBoolean(showWorktreesKey, showWorktrees)
41804224
this.showWorktrees = showWorktrees
4225+
if (!showWorktrees && this.showWorktreesInSidebar) {
4226+
setBoolean(showWorktreesInSidebarKey, false)
4227+
this.showWorktreesInSidebar = false
4228+
this.lastSidebarWorktreeRefreshAt.clear()
4229+
}
41814230
this.updateResizableConstraints()
41824231
this.emitUpdate()
41834232
}
41844233

4234+
public _setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) {
4235+
if (this.showWorktreesInSidebar === showWorktreesInSidebar) {
4236+
return
4237+
}
4238+
4239+
if (showWorktreesInSidebar && !this.showWorktrees) {
4240+
return
4241+
}
4242+
4243+
setBoolean(showWorktreesInSidebarKey, showWorktreesInSidebar)
4244+
this.showWorktreesInSidebar = showWorktreesInSidebar
4245+
this.lastSidebarWorktreeRefreshAt.clear()
4246+
this.emitUpdate()
4247+
}
4248+
41854249
public _setShowCompareTab(showCompareTab: boolean) {
41864250
if (this.showCompareTab === showCompareTab) {
41874251
return

app/src/models/repository.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@ import * as Path from 'path'
22

33
import { GitHubRepository, ForkedGitHubRepository } from './github-repository'
44
import { IAheadBehind } from './branch'
5+
import { WorktreeEntry } from './worktree'
56
import {
67
WorkflowPreferences,
78
ForkContributionTarget,
89
} from './workflow-preferences'
910
import { assertNever, fatalError } from '../lib/fatal-error'
1011
import { createEqualityHash } from './equality-hash'
11-
import { isLinkedWorktreeSync } from '../lib/git/worktree'
12+
import {
13+
isLinkedWorktreeSync,
14+
getMainWorktreePathSync,
15+
} from '../lib/git/worktree'
1216
import { getRemotes } from '../lib/git'
1317
import { findDefaultRemote } from '../lib/stores/helpers/find-default-remote'
1418
import { isTrustedRemoteHost } from '../lib/api'
@@ -57,6 +61,7 @@ export class Repository {
5761
private _url: string | null = null
5862

5963
private _isLinkedWorktree: boolean | undefined = undefined
64+
private _mainWorktreePath: string | undefined = undefined
6065

6166
/**
6267
* @param path The working directory of this repository
@@ -109,6 +114,14 @@ export class Repository {
109114
return this._isLinkedWorktree
110115
}
111116

117+
public get mainWorktreePath(): string {
118+
if (this._mainWorktreePath === undefined) {
119+
this._mainWorktreePath = getMainWorktreePathSync(this.path) ?? this.path
120+
}
121+
122+
return this._mainWorktreePath
123+
}
124+
112125
public get url(): string | null {
113126
// Resolve the default remote URL if not yet done.
114127
if (this._url === null) {
@@ -229,6 +242,10 @@ export interface ILocalRepositoryState {
229242
* The name of the default branch, or `undefined` if not available.
230243
*/
231244
readonly defaultBranchName: string | null
245+
/**
246+
* All worktrees known for this repository.
247+
*/
248+
readonly allWorktrees: ReadonlyArray<WorktreeEntry>
232249
}
233250

234251
/**

app/src/ui/app.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,7 @@ export class App extends React.Component<IAppProps, IAppState> {
16861686
titleBarStyle={this.state.titleBarStyle}
16871687
showRecentRepositories={this.state.showRecentRepositories}
16881688
showWorktrees={this.state.showWorktrees}
1689+
showWorktreesInSidebar={this.state.showWorktreesInSidebar}
16891690
showCompareTab={this.state.showCompareTab}
16901691
repositoryIndicatorsEnabled={this.state.repositoryIndicatorsEnabled}
16911692
hideWindowOnQuit={this.state.hideWindowOnQuit}
@@ -3067,9 +3068,11 @@ export class App extends React.Component<IAppProps, IAppState> {
30673068

30683069
const { useCustomShell, selectedShell } = this.state
30693070
const filterText = this.state.repositoryFilterText
3070-
const repositories = this.state.repositories.filter(
3071-
r => !(r instanceof Repository && r.isLinkedWorktree)
3072-
)
3071+
const repositories = this.state.showWorktreesInSidebar
3072+
? this.state.repositories
3073+
: this.state.repositories.filter(
3074+
r => !(r instanceof Repository && r.isLinkedWorktree)
3075+
)
30733076
return (
30743077
<RepositoriesList
30753078
filterText={filterText}
@@ -3093,6 +3096,7 @@ export class App extends React.Component<IAppProps, IAppState> {
30933096
shellLabel={useCustomShell ? undefined : selectedShell}
30943097
dispatcher={this.props.dispatcher}
30953098
showBranchNameInRepoList={this.state.showBranchNameInRepoList}
3099+
showWorktreesInSidebar={this.state.showWorktreesInSidebar}
30963100
/>
30973101
)
30983102
}

app/src/ui/dispatcher/dispatcher.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2928,6 +2928,10 @@ export class Dispatcher {
29282928
this.appStore._setShowWorktrees(showWorktrees)
29292929
}
29302930

2931+
public setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) {
2932+
this.appStore._setShowWorktreesInSidebar(showWorktreesInSidebar)
2933+
}
2934+
29312935
public setShowCompareTab(showCompareTab: boolean) {
29322936
this.appStore._setShowCompareTab(showCompareTab)
29332937
}

app/src/ui/preferences/appearance.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ interface IAppearanceProps {
2929
readonly onShowRecentRepositoriesChanged: (show: boolean) => void
3030
readonly showWorktrees: boolean
3131
readonly onShowWorktreesChanged: (show: boolean) => void
32+
readonly showWorktreesInSidebar: boolean
33+
readonly onShowWorktreesInSidebarChanged: (show: boolean) => void
3234
readonly showCompareTab: boolean
3335
readonly onShowCompareTabChanged: (show: boolean) => void
3436
readonly showBranchNameInRepoList: ShowBranchNameInRepoListSetting
@@ -47,6 +49,7 @@ interface IAppearanceState {
4749
readonly titleBarStyle: TitleBarStyle
4850
readonly showRecentRepositories: boolean
4951
readonly showWorktrees: boolean
52+
readonly showWorktreesInSidebar: boolean
5053
readonly showCompareTab: boolean
5154
}
5255

@@ -76,6 +79,7 @@ export class Appearance extends React.Component<
7679
titleBarStyle: props.titleBarStyle,
7780
showRecentRepositories: props.showRecentRepositories,
7881
showWorktrees: props.showWorktrees,
82+
showWorktreesInSidebar: props.showWorktreesInSidebar,
7983
showCompareTab: props.showCompareTab,
8084
}
8185

@@ -136,6 +140,14 @@ export class Appearance extends React.Component<
136140
this.props.onShowCompareTabChanged(show)
137141
}
138142

143+
private onShowWorktreesInSidebarChanged = (
144+
event: React.FormEvent<HTMLInputElement>
145+
) => {
146+
const show = event.currentTarget.checked
147+
this.setState({ showWorktreesInSidebar: show })
148+
this.props.onShowWorktreesInSidebarChanged(show)
149+
}
150+
139151
private onSelectedTabSizeChanged = (
140152
event: React.FormEvent<HTMLSelectElement>
141153
) => {
@@ -363,6 +375,17 @@ export class Appearance extends React.Component<
363375
}
364376
onChange={this.onShowWorktreesChanged}
365377
/>
378+
{this.state.showWorktrees && (
379+
<Checkbox
380+
label="Show worktrees in repository sidebar"
381+
value={
382+
this.state.showWorktreesInSidebar
383+
? CheckboxValue.On
384+
: CheckboxValue.Off
385+
}
386+
onChange={this.onShowWorktreesInSidebarChanged}
387+
/>
388+
)}
366389
</div>
367390
<div className="advanced-section">
368391
<h2>{'Commit list'}</h2>

0 commit comments

Comments
 (0)