Skip to content

Commit b3dbfe1

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 - preload main-repository worktree state for the sidebar, show a loading hint while discovery is in flight, and refresh the parent row when a linked worktree is selected - use preloaded parent worktree metadata so stored linked worktree rows show their branch pill on first render instead of waiting for an explicit refresh - add unit coverage for grouped, synthetic, loading, and stored-linked branch-label cases 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/models/repository.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/test/unit/repositories-list-grouping-test.ts - yarn compile:dev
1 parent bce9f0a commit b3dbfe1

15 files changed

Lines changed: 1004 additions & 62 deletions

File tree

.github/actions/setup-ci-environment/action.yml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,22 @@ runs:
3333

3434
- name: Install and build dependencies
3535
shell: bash
36-
run: yarn
36+
run: |
37+
max_attempts=3
38+
39+
for attempt in $(seq 1 "$max_attempts"); do
40+
if yarn; then
41+
exit 0
42+
fi
43+
44+
if [ "$attempt" -eq "$max_attempts" ]; then
45+
exit 1
46+
fi
47+
48+
sleep_seconds=$((attempt * 10))
49+
echo "yarn install failed on attempt $attempt/$max_attempts, retrying in ${sleep_seconds}s..."
50+
sleep "$sleep_seconds"
51+
done
3752
env:
3853
npm_config_arch: ${{ inputs.arch }}
3954
TARGET_ARCH: ${{ inputs.arch }}

app/src/lib/app-state.ts

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

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

app/src/lib/git/worktree.ts

Lines changed: 58 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,38 @@ 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(
196+
Path.join(gitDirPath, 'commondir'),
197+
'utf8'
198+
).trim()
199+
if (commondir.length === 0) {
200+
return null
201+
}
202+
203+
const commonGitDir = Path.resolve(gitDirPath, commondir)
204+
return Path.dirname(commonGitDir)
205+
} catch {
206+
return null
207+
}
208+
}

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

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,15 @@ import { BranchPruner } from './helpers/branch-pruner'
377377
import { createTutorialRepository } from './helpers/create-tutorial-repository'
378378
import { findRemoteBranchName } from './helpers/find-branch-name'
379379
import { RepositoryIndicatorUpdater } from './helpers/repository-indicator-updater'
380+
import {
381+
createInitialLoadingSidebarState,
382+
createLoadedSidebarState,
383+
createLoadingSidebarState,
384+
createSidebarStateFromStatus,
385+
findSidebarWorktreeStateRepository,
386+
getCurrentWorktreeEntryForRepository,
387+
shouldRefreshSidebarWorktrees,
388+
} from './helpers/sidebar-worktrees'
380389
import { OnboardingTutorialAssessor } from './helpers/tutorial-assessor'
381390
import {
382391
getNotificationsEnabled,
@@ -471,6 +480,7 @@ const shellKey = 'shell'
471480

472481
const showRecentRepositoriesKey = 'show-recent-repositories'
473482
const showWorktreesKey = 'show-worktrees'
483+
const showWorktreesInSidebarKey = 'show-worktrees-in-sidebar'
474484
const showCompareTabKey = 'show-compare-tab'
475485
const showCompareTabDefault = true
476486
const repositoryIndicatorsEnabledKey = 'enable-repository-indicators'
@@ -641,6 +651,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
641651
private titleBarStyle: TitleBarStyle = 'native'
642652
private showRecentRepositories: boolean = true
643653
private showWorktrees: boolean = false
654+
private showWorktreesInSidebar: boolean = false
655+
private readonly lastSidebarWorktreeRefreshAt = new Map<string, number>()
644656
private showCompareTab: boolean = showCompareTabDefault
645657
private hideWindowOnQuit: boolean = __DARWIN__
646658

@@ -762,6 +774,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
762774

763775
this.showRecentRepositories = getBoolean(showRecentRepositoriesKey) ?? true
764776
this.showWorktrees = getBoolean(showWorktreesKey) ?? false
777+
this.showWorktreesInSidebar =
778+
this.showWorktrees && (getBoolean(showWorktreesInSidebarKey) ?? false)
765779
this.showCompareTab = getBoolean(showCompareTabKey, showCompareTabDefault)
766780

767781
this.repositoryIndicatorUpdater = new RepositoryIndicatorUpdater(
@@ -1034,6 +1048,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
10341048
this.repositoriesStore.onDidUpdate(updateRepositories => {
10351049
this.repositories = updateRepositories
10361050
this.updateRepositorySelectionAfterRepositoriesChanged()
1051+
if (this.showWorktreesInSidebar) {
1052+
void this.preloadSidebarWorktrees()
1053+
}
10371054
this.emitUpdate()
10381055
})
10391056

@@ -1215,6 +1232,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
12151232
titleBarStyle: this.titleBarStyle,
12161233
showRecentRepositories: this.showRecentRepositories,
12171234
showWorktrees: this.showWorktrees,
1235+
showWorktreesInSidebar: this.showWorktreesInSidebar,
12181236
showCompareTab: this.showCompareTab,
12191237
apiRepositories: this.apiRepositoriesStore.getState(),
12201238
useWindowsOpenSSH: this.useWindowsOpenSSH,
@@ -2463,6 +2481,9 @@ export class AppStore extends TypedBaseStore<IAppState> {
24632481
this.repositories = repositories
24642482

24652483
this.updateRepositorySelectionAfterRepositoriesChanged()
2484+
if (this.showWorktreesInSidebar) {
2485+
void this.preloadSidebarWorktrees()
2486+
}
24662487

24672488
this.sidebarWidth = constrain(
24682489
getNumber(sidebarWidthConfigKey, defaultSidebarWidth)
@@ -3959,6 +3980,10 @@ export class AppStore extends TypedBaseStore<IAppState> {
39593980

39603981
const state = this.repositoryStateCache.get(repository)
39613982
const gitStore = this.gitStoreCache.get(repository)
3983+
const sidebarRepository = findSidebarWorktreeStateRepository(
3984+
this.repositories,
3985+
repository
3986+
)
39623987

39633988
// if we cannot get a valid status it's a good indicator that the repository
39643989
// is in a bad state - let's mark it as missing here and give up on the
@@ -3971,10 +3996,43 @@ export class AppStore extends TypedBaseStore<IAppState> {
39713996
return
39723997
}
39733998

3999+
if (this.showWorktreesInSidebar) {
4000+
const existing = this.localRepositoryStateLookup.get(sidebarRepository.id)
4001+
this.localRepositoryStateLookup.set(
4002+
sidebarRepository.id,
4003+
createLoadingSidebarState(sidebarRepository, status, existing)
4004+
)
4005+
this.emitUpdate()
4006+
}
4007+
39744008
// loadBranches needs the default remote to determine the default branch
39754009
await gitStore.loadRemotes()
39764010
await gitStore.loadBranches()
39774011
await gitStore.loadWorktrees()
4012+
this.repositoryStateCache.updateWorktreesState(repository, () => ({
4013+
allWorktrees: gitStore.allWorktrees,
4014+
currentWorktree: gitStore.currentWorktree,
4015+
}))
4016+
if (sidebarRepository !== repository) {
4017+
this.repositoryStateCache.updateWorktreesState(sidebarRepository, () => ({
4018+
allWorktrees: gitStore.allWorktrees,
4019+
currentWorktree: getCurrentWorktreeEntryForRepository(
4020+
gitStore.allWorktrees,
4021+
sidebarRepository
4022+
),
4023+
}))
4024+
}
4025+
this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now())
4026+
this.lastSidebarWorktreeRefreshAt.set(sidebarRepository.hash, Date.now())
4027+
this.updateSidebarIndicator(repository, status)
4028+
const refreshed = this.localRepositoryStateLookup.get(sidebarRepository.id)
4029+
if (refreshed !== undefined) {
4030+
this.localRepositoryStateLookup.set(
4031+
sidebarRepository.id,
4032+
createLoadedSidebarState(refreshed, gitStore.allWorktrees)
4033+
)
4034+
}
4035+
this.emitUpdate()
39784036

39794037
const section = state.selectedSection
39804038
let refreshSectionPromise: Promise<void>
@@ -4026,6 +4084,58 @@ export class AppStore extends TypedBaseStore<IAppState> {
40264084
}
40274085
}
40284086

4087+
private async preloadSidebarWorktrees() {
4088+
for (const repository of this.repositories) {
4089+
if (repository.isLinkedWorktree) {
4090+
continue
4091+
}
4092+
4093+
const existing = this.localRepositoryStateLookup.get(repository.id)
4094+
this.localRepositoryStateLookup.set(
4095+
repository.id,
4096+
createInitialLoadingSidebarState(repository, existing)
4097+
)
4098+
}
4099+
this.emitUpdate()
4100+
4101+
for (const repository of this.repositories) {
4102+
if (repository.isLinkedWorktree) {
4103+
continue
4104+
}
4105+
4106+
const exists = await pathExists(repository.path)
4107+
if (!exists) {
4108+
continue
4109+
}
4110+
4111+
try {
4112+
const gitStore = this.gitStoreCache.get(repository)
4113+
await gitStore.loadWorktrees()
4114+
this.repositoryStateCache.updateWorktreesState(repository, () => ({
4115+
allWorktrees: gitStore.allWorktrees,
4116+
currentWorktree: gitStore.currentWorktree,
4117+
}))
4118+
4119+
const existing = this.localRepositoryStateLookup.get(repository.id)
4120+
if (existing !== undefined) {
4121+
this.localRepositoryStateLookup.set(
4122+
repository.id,
4123+
createLoadedSidebarState(existing, gitStore.allWorktrees)
4124+
)
4125+
}
4126+
this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now())
4127+
this.emitUpdate()
4128+
} catch (error) {
4129+
log.warn(
4130+
`[AppStore] Failed to preload sidebar worktrees for '${nameOf(
4131+
repository
4132+
)}'`,
4133+
error
4134+
)
4135+
}
4136+
}
4137+
}
4138+
40294139
private async updateStashEntryCountMetric(
40304140
repository: Repository,
40314141
desktopStashEntryCount: number,
@@ -4065,12 +4175,16 @@ export class AppStore extends TypedBaseStore<IAppState> {
40654175
return
40664176
}
40674177

4068-
lookup.set(repository.id, {
4069-
aheadBehind: status.branchAheadBehind || null,
4070-
changedFilesCount: status.workingDirectory.files.length,
4071-
branchName: status.currentBranch || null,
4072-
defaultBranchName: repository.defaultBranch,
4073-
})
4178+
lookup.set(
4179+
repository.id,
4180+
createSidebarStateFromStatus(
4181+
repository,
4182+
status,
4183+
lookup.get(repository.id),
4184+
this.repositoryStateCache.get(repository).worktreesState.allWorktrees,
4185+
this.showWorktreesInSidebar
4186+
)
4187+
)
40744188
}
40754189
/**
40764190
* Refresh indicator in repository list for a specific repository
@@ -4096,6 +4210,37 @@ export class AppStore extends TypedBaseStore<IAppState> {
40964210
return
40974211
}
40984212

4213+
if (
4214+
this.showWorktreesInSidebar &&
4215+
!repository.isLinkedWorktree &&
4216+
shouldRefreshSidebarWorktrees(
4217+
this.lastSidebarWorktreeRefreshAt.get(repository.hash),
4218+
this.repositoryStateCache.get(repository).worktreesState.allWorktrees
4219+
)
4220+
) {
4221+
this.updateSidebarIndicator(repository, status)
4222+
const existing = lookup.get(repository.id)
4223+
lookup.set(
4224+
repository.id,
4225+
createLoadingSidebarState(repository, status, existing)
4226+
)
4227+
this.emitUpdate()
4228+
4229+
await gitStore.loadWorktrees()
4230+
this.repositoryStateCache.updateWorktreesState(repository, () => ({
4231+
allWorktrees: gitStore.allWorktrees,
4232+
currentWorktree: gitStore.currentWorktree,
4233+
}))
4234+
const refreshed = lookup.get(repository.id)
4235+
if (refreshed !== undefined) {
4236+
lookup.set(
4237+
repository.id,
4238+
createLoadedSidebarState(refreshed, gitStore.allWorktrees)
4239+
)
4240+
}
4241+
this.lastSidebarWorktreeRefreshAt.set(repository.hash, Date.now())
4242+
}
4243+
40994244
this.updateSidebarIndicator(repository, status)
41004245
this.emitUpdate()
41014246

@@ -4117,6 +4262,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
41174262
changedFilesCount: existing?.changedFilesCount ?? 0,
41184263
branchName: existing?.branchName ?? null,
41194264
defaultBranchName: existing?.defaultBranchName ?? null,
4265+
isLoadingWorktrees: existing?.isLoadingWorktrees ?? false,
4266+
allWorktrees: existing?.allWorktrees ?? [],
41204267
})
41214268
this.emitUpdate()
41224269
}
@@ -4191,10 +4338,30 @@ export class AppStore extends TypedBaseStore<IAppState> {
41914338
}
41924339
setBoolean(showWorktreesKey, showWorktrees)
41934340
this.showWorktrees = showWorktrees
4341+
if (!showWorktrees && this.showWorktreesInSidebar) {
4342+
setBoolean(showWorktreesInSidebarKey, false)
4343+
this.showWorktreesInSidebar = false
4344+
this.lastSidebarWorktreeRefreshAt.clear()
4345+
}
41944346
this.updateResizableConstraints()
41954347
this.emitUpdate()
41964348
}
41974349

4350+
public _setShowWorktreesInSidebar(showWorktreesInSidebar: boolean) {
4351+
if (this.showWorktreesInSidebar === showWorktreesInSidebar) {
4352+
return
4353+
}
4354+
4355+
if (showWorktreesInSidebar && !this.showWorktrees) {
4356+
return
4357+
}
4358+
4359+
setBoolean(showWorktreesInSidebarKey, showWorktreesInSidebar)
4360+
this.showWorktreesInSidebar = showWorktreesInSidebar
4361+
this.lastSidebarWorktreeRefreshAt.clear()
4362+
this.emitUpdate()
4363+
}
4364+
41984365
public _setShowCompareTab(showCompareTab: boolean) {
41994366
if (this.showCompareTab === showCompareTab) {
42004367
return

0 commit comments

Comments
 (0)