From d4817c6e9d4ea61002d188740fe50d99be9d4645 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:08:06 +0900 Subject: [PATCH 01/31] Add worktree operations layer and types --- app/src/lib/git/index.ts | 1 + app/src/lib/git/worktree.ts | 126 ++++++++++++++++++++++ app/src/models/worktree.ts | 12 +++ app/test/unit/git/worktree-test.ts | 164 +++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 app/src/lib/git/worktree.ts create mode 100644 app/src/models/worktree.ts create mode 100644 app/test/unit/git/worktree-test.ts 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..a3a917e5466 --- /dev/null +++ b/app/src/lib/git/worktree.ts @@ -0,0 +1,126 @@ +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 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 +} + +function normalizePath(p: string): string { + return p.replace(/\/+$/, '') +} 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/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) + }) + }) +}) From 3823c83af7c166a0979cbcd552e3eb630803e866 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:08:12 +0900 Subject: [PATCH 02/31] Add FoldoutType.Worktree, MenuEvent, and Cmd+E shortcut --- app/src/lib/app-state.ts | 2 ++ .../main-process/menu/build-default-menu.ts | 6 ++++++ app/src/main-process/menu/menu-event.ts | 1 + app/src/ui/app.tsx | 18 ++++++++++++++++++ app/src/ui/dispatcher/dispatcher.ts | 10 ++++++++++ 5 files changed, 37 insertions(+) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 9b50d3f7ecc..6403b10478a 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -411,6 +411,7 @@ export enum FoldoutType { AppMenu, AddMenu, PushPull, + Worktree, } export type AppMenuFoldout = { @@ -434,6 +435,7 @@ export type Foldout = | BranchFoldout | AppMenuFoldout | { type: FoldoutType.PushPull } + | { type: FoldoutType.Worktree } export enum RepositorySectionTab { Changes, diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index f2a12263b59..77ca8a18dee 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', 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/ui/app.tsx b/app/src/ui/app.tsx index 93339ec11f9..1ebd55338da 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -445,6 +445,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 +956,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) { diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 24a32f44861..1afa53ed573 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. * From 1e981672c8cb15691c6c0716860119f8a1932593 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:19:51 +0900 Subject: [PATCH 03/31] Add WorktreeDropdown toolbar component --- app/src/ui/toolbar/index.tsx | 1 + app/src/ui/toolbar/worktree-dropdown.tsx | 141 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 app/src/ui/toolbar/worktree-dropdown.tsx 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..9a67c826c17 --- /dev/null +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import { Dispatcher } from '../dispatcher' +import * as octicons from '../octicons/octicons.generated' +import { Repository } from '../../models/repository' +import { ToolbarDropdown, DropdownState } from './dropdown' +import { FoldoutType } from '../../lib/app-state' +import { WorktreeEntry } from '../../models/worktree' +import { WorktreeList } from '../worktrees/worktree-list' +import { listWorktrees, isLinkedWorktree } from '../../lib/git/worktree' +import { CloningRepository } from '../../models/cloning-repository' + +interface IWorktreeDropdownProps { + readonly dispatcher: Dispatcher + readonly repository: Repository + readonly isOpen: boolean + readonly onDropDownStateChanged: (state: DropdownState) => void + readonly enableFocusTrap: boolean + readonly repositories: ReadonlyArray +} + +interface IWorktreeDropdownState { + readonly worktrees: ReadonlyArray + readonly filterText: string + readonly isCurrentRepoLinkedWorktree: boolean +} + +export class WorktreeDropdown extends React.Component< + IWorktreeDropdownProps, + IWorktreeDropdownState +> { + public constructor(props: IWorktreeDropdownProps) { + super(props) + this.state = { + worktrees: [], + filterText: '', + isCurrentRepoLinkedWorktree: false, + } + } + + public componentDidUpdate(prevProps: IWorktreeDropdownProps) { + if (!prevProps.isOpen && this.props.isOpen) { + this.fetchWorktrees() + } + } + + private async fetchWorktrees() { + const { repository } = this.props + + try { + const [worktrees, isLinked] = await Promise.all([ + listWorktrees(repository), + isLinkedWorktree(repository), + ]) + + this.setState({ + worktrees, + isCurrentRepoLinkedWorktree: isLinked, + }) + } catch (e) { + log.error('Failed to fetch worktrees', e) + this.setState({ + worktrees: [], + isCurrentRepoLinkedWorktree: false, + }) + } + } + + private onWorktreeClick = async (worktree: WorktreeEntry) => { + const { dispatcher, repositories } = this.props + const worktreePath = normalizePath(worktree.path) + + 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) + } else { + const addedRepos = await dispatcher.addRepositories([worktree.path]) + + if (addedRepos.length > 0) { + await dispatcher.selectRepository(addedRepos[0]) + } + } + } + + 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 + ) + } + + public render() { + const { isOpen, enableFocusTrap } = this.props + const currentState: DropdownState = isOpen ? 'open' : 'closed' + + return ( + + ) + } +} + +function normalizePath(p: string): string { + return p.replace(/\/+$/, '') +} From 9ee2aa0857cfbef8afd4915828c6fa75f0f0eb61 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:19:58 +0900 Subject: [PATCH 04/31] Add WorktreeList and WorktreeListItem components --- app/src/ui/worktrees/worktree-list-item.tsx | 47 ++++++ app/src/ui/worktrees/worktree-list.tsx | 161 ++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 app/src/ui/worktrees/worktree-list-item.tsx create mode 100644 app/src/ui/worktrees/worktree-list.tsx 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..971fe250495 --- /dev/null +++ b/app/src/ui/worktrees/worktree-list-item.tsx @@ -0,0 +1,47 @@ +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('branches-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..3720ca796d0 --- /dev/null +++ b/app/src/ui/worktrees/worktree-list.tsx @@ -0,0 +1,161 @@ +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 { 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 renderItem = (item: IWorktreeListItem, matches: IMatches) => { + return ( + + ) + } + + 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 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} + /> + ) + } +} From 0aa25dcc4d4f2789b92af06d692e49b671b9a2ab Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:20:06 +0900 Subject: [PATCH 05/31] Add worktree context menu and add-worktree dialog --- app/src/models/popup.ts | 5 + app/src/ui/app.tsx | 3 + app/src/ui/worktrees/add-worktree-dialog.tsx | 119 ++++++++++++++++++ .../worktree-list-item-context-menu.ts | 41 ++++++ 4 files changed, 168 insertions(+) create mode 100644 app/src/ui/worktrees/add-worktree-dialog.tsx create mode 100644 app/src/ui/worktrees/worktree-list-item-context-menu.ts diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index f75dcf0b426..907c6d900ba 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -109,6 +109,7 @@ export enum PopupType { GenerateCommitMessageDisclaimer = 'GenerateCommitMessageDisclaimer', HookFailed = 'HookFailed', CommitProgress = 'CommitProgress', + AddWorktree = 'AddWorktree', } interface IBasePopup { @@ -484,4 +485,8 @@ export type PopupDetail = type: PopupType.CommitProgress subscribeToCommitOutput: TerminalOutputListener } + | { + type: PopupType.AddWorktree + repository: Repository + } export type Popup = IBasePopup & PopupDetail diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 1ebd55338da..2c436fb91a6 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -2642,6 +2642,9 @@ export class App extends React.Component { /> ) } + case PopupType.AddWorktree: { + return null + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } 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..41de2f538bc --- /dev/null +++ b/app/src/ui/worktrees/add-worktree-dialog.tsx @@ -0,0 +1,119 @@ +import * as React from 'react' + +import { Repository } from '../../models/repository' +import { Dispatcher } from '../dispatcher' +import { Dialog, DialogContent, DialogFooter } from '../dialog' +import { TextBox } from '../lib/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 path: string + readonly branchName: string + readonly creating: boolean +} + +export class AddWorktreeDialog extends React.Component< + IAddWorktreeDialogProps, + IAddWorktreeDialogState +> { + public constructor(props: IAddWorktreeDialogProps) { + super(props) + + this.state = { + path: '', + branchName: '', + creating: false, + } + } + + private onPathChanged = (path: string) => { + this.setState({ path }) + } + + private onBranchNameChanged = (branchName: string) => { + this.setState({ branchName }) + } + + private showFilePicker = async () => { + const path = await showOpenDialog({ + properties: ['createDirectory', 'openDirectory'], + }) + + if (path === null) { + return + } + + this.setState({ path }) + } + + private onSubmit = async () => { + const { path, branchName } = this.state + + this.setState({ creating: true }) + + try { + await addWorktree(this.props.repository, path, { + createBranch: branchName.length > 0 ? branchName : undefined, + }) + } catch (e) { + this.props.dispatcher.postError(e) + this.setState({ creating: false }) + return + } + + this.setState({ creating: false }) + this.props.onDismissed() + } + + public render() { + const disabled = this.state.path.length === 0 || this.state.creating + + 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..346aff8077f --- /dev/null +++ b/app/src/ui/worktrees/worktree-list-item-context-menu.ts @@ -0,0 +1,41 @@ +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 onRemoveWorktree?: (path: string) => void +} + +export function generateWorktreeContextMenuItems( + config: IWorktreeContextMenuConfig +): ReadonlyArray { + const { path, isMainWorktree, isLocked, onRemoveWorktree } = config + const name = Path.basename(path) + const items = new Array() + + 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 +} From 0c3457345b04f17b7c45b60ed8238e9cb3bac993 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:41:57 +0900 Subject: [PATCH 06/31] Add worktree dropdown and list styling Created _worktree-dropdown.scss for toolbar button styling and _worktrees.scss for list component styling. Updated WorktreeListItem to use specific worktrees-list-item class. Styling follows existing design system patterns from branches UI. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/worktrees/worktree-list-item.tsx | 2 +- app/styles/_ui.scss | 2 + app/styles/ui/_worktrees.scss | 71 +++++++++++++++++++ app/styles/ui/toolbar/_worktree-dropdown.scss | 14 ++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 app/styles/ui/_worktrees.scss create mode 100644 app/styles/ui/toolbar/_worktree-dropdown.scss diff --git a/app/src/ui/worktrees/worktree-list-item.tsx b/app/src/ui/worktrees/worktree-list-item.tsx index 971fe250495..4f2c7a1fa50 100644 --- a/app/src/ui/worktrees/worktree-list-item.tsx +++ b/app/src/ui/worktrees/worktree-list-item.tsx @@ -20,7 +20,7 @@ export class WorktreeListItem extends React.Component { const { worktree, isCurrentWorktree, matches } = this.props const name = Path.basename(worktree.path) const icon = isCurrentWorktree ? octicons.check : octicons.fileDirectory - const className = classNames('branches-list-item', { + const className = classNames('worktrees-list-item', { 'current-worktree': isCurrentWorktree, }) 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/_worktrees.scss b/app/styles/ui/_worktrees.scss new file mode 100644 index 00000000000..229dd7f1f6b --- /dev/null +++ b/app/styles/ui/_worktrees.scss @@ -0,0 +1,71 @@ +@import '../mixins'; + +.worktree-list { + display: flex; + flex-direction: column; + width: 365px; + 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 { + margin: var(--spacing); + width: calc(100% - (var(--spacing) * 2)); + } + + .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..786990880fe --- /dev/null +++ b/app/styles/ui/toolbar/_worktree-dropdown.scss @@ -0,0 +1,14 @@ +.worktree-button { + max-width: 250px; + + .toolbar-dropdown-button { + // Ensure the button doesn't get too wide + max-width: 100%; + } + + // Ensure title and description truncate properly + .title, + .description { + @include ellipsis; + } +} From 9638c79ae8735d5cbc4eb480ea2eb3db549dca23 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 01:42:16 +0900 Subject: [PATCH 07/31] Integrate worktree toolbar button and menu shortcut in app Added WorktreeDropdown rendering, onWorktreeDropdownStateChanged handler, and AddWorktreeDialog popup rendering. Toolbar button positioned between Branch and PushPull buttons. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/app.tsx | 48 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 2c436fb91a6..b35da44e63e 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,7 @@ 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' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2643,7 +2645,14 @@ export class App extends React.Component { ) } case PopupType.AddWorktree: { - return null + return ( + + ) } default: return assertNever(popup, `Unknown popup type: ${popup}`) @@ -3340,6 +3349,14 @@ export class App extends React.Component { } } + 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 @@ -3383,6 +3400,34 @@ 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 + } + + 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 @@ -3460,6 +3505,7 @@ export class App extends React.Component { {this.renderRepositoryToolbarButton()} {this.renderBranchToolbarButton()} + {this.renderWorktreeToolbarButton()} {this.renderPushPullToolbarButton()} ) From d8a1e6bd412f2cc63af27ecbab2af01beb09a7a5 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 13:51:26 +0900 Subject: [PATCH 08/31] Fix worktree list not rendering items in dropdown The worktree list was missing height: 100% on its container, causing the SectionList inside to collapse to zero height. The filter input was visible but no list items appeared below it. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/styles/ui/_worktrees.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/styles/ui/_worktrees.scss b/app/styles/ui/_worktrees.scss index 229dd7f1f6b..d712324fa99 100644 --- a/app/styles/ui/_worktrees.scss +++ b/app/styles/ui/_worktrees.scss @@ -1,6 +1,7 @@ @import '../mixins'; .worktree-list { + height: 100%; display: flex; flex-direction: column; width: 365px; From f29e2060f46d2d54f10f8e8df31121e4f981ec86 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 15:51:45 +0900 Subject: [PATCH 09/31] Fix worktree dropdown dismissing on any keyboard input onWorktreeSelected was wired to onWorktreeClick, which closes the foldout and switches repository. SectionFilterList fires onSelectionChanged on every filter keystroke as the selected row shifts, so typing in the filter would immediately close the dropdown. Separated selection tracking (no-op) from click navigation. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/toolbar/worktree-dropdown.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index 9a67c826c17..8a346257cd0 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -86,6 +86,9 @@ export class WorktreeDropdown extends React.Component< } } + // Intentional no-op: navigation happens on click, not selection change + private onWorktreeSelected = (_worktree: WorktreeEntry) => {} + private onFilterTextChanged = (text: string) => { this.setState({ filterText: text }) } @@ -98,7 +101,7 @@ export class WorktreeDropdown extends React.Component< worktrees={worktrees} currentWorktree={this.getCurrentWorktree()} selectedWorktree={null} - onWorktreeSelected={this.onWorktreeClick} + onWorktreeSelected={this.onWorktreeSelected} onWorktreeClick={this.onWorktreeClick} filterText={this.state.filterText} onFilterTextChanged={this.onFilterTextChanged} From 2b4a8d763f3ac4c75e4beffcef3a67d99d964f3d Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 10 Feb 2026 16:10:48 +0900 Subject: [PATCH 10/31] Auto-remove worktree repo from list when switching away Worktree repos added via the dropdown are now tracked and automatically removed from the repository list when the user switches to a different worktree or back to the original repo, keeping the sidebar clean. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/toolbar/worktree-dropdown.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index 8a346257cd0..4d552caf951 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -22,6 +22,7 @@ interface IWorktreeDropdownState { readonly worktrees: ReadonlyArray readonly filterText: string readonly isCurrentRepoLinkedWorktree: boolean + readonly worktreeAddedRepo: Repository | null } export class WorktreeDropdown extends React.Component< @@ -34,6 +35,7 @@ export class WorktreeDropdown extends React.Component< worktrees: [], filterText: '', isCurrentRepoLinkedWorktree: false, + worktreeAddedRepo: null, } } @@ -68,6 +70,7 @@ export class WorktreeDropdown extends React.Component< private onWorktreeClick = async (worktree: WorktreeEntry) => { const { dispatcher, repositories } = this.props const worktreePath = normalizePath(worktree.path) + const previousWorktreeRepo = this.state.worktreeAddedRepo dispatcher.closeFoldout(FoldoutType.Worktree) @@ -77,13 +80,20 @@ export class WorktreeDropdown extends React.Component< 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 From 69e59dc7b6271848d299e4675b36c267afdf4236 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 17:57:58 +0900 Subject: [PATCH 11/31] Wire up worktree context menu with delete action in dropdown Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/toolbar/worktree-dropdown.tsx | 36 +++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index 4d552caf951..5debae5e988 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -6,8 +6,14 @@ import { ToolbarDropdown, DropdownState } from './dropdown' import { FoldoutType } from '../../lib/app-state' import { WorktreeEntry } from '../../models/worktree' import { WorktreeList } from '../worktrees/worktree-list' -import { listWorktrees, isLinkedWorktree } from '../../lib/git/worktree' +import { + listWorktrees, + isLinkedWorktree, + removeWorktree, +} 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' interface IWorktreeDropdownProps { readonly dispatcher: Dispatcher @@ -99,6 +105,33 @@ export class WorktreeDropdown extends React.Component< // 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, + onRemoveWorktree: this.onRemoveWorktree, + }) + + showContextualMenu(items) + } + + private onRemoveWorktree = async (path: string) => { + const { repository } = this.props + + try { + await removeWorktree(repository, path) + await this.fetchWorktrees() + } catch (e) { + log.error('Failed to remove worktree', e) + } + } + private onFilterTextChanged = (text: string) => { this.setState({ filterText: text }) } @@ -116,6 +149,7 @@ export class WorktreeDropdown extends React.Component< filterText={this.state.filterText} onFilterTextChanged={this.onFilterTextChanged} canCreateNewWorktree={false} + onWorktreeContextMenu={this.onWorktreeContextMenu} /> ) } From 949492c57da849c816172a2ec497116e92df8e53 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 18:01:53 +0900 Subject: [PATCH 12/31] Add moveWorktree git operation and RenameWorktree popup type Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/lib/git/worktree.ts | 12 ++++++++++++ app/src/models/popup.ts | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/app/src/lib/git/worktree.ts b/app/src/lib/git/worktree.ts index a3a917e5466..2b981fd0bbf 100644 --- a/app/src/lib/git/worktree.ts +++ b/app/src/lib/git/worktree.ts @@ -101,6 +101,18 @@ export async function removeWorktree( 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 { diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index 907c6d900ba..d55129eee53 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -110,6 +110,7 @@ export enum PopupType { HookFailed = 'HookFailed', CommitProgress = 'CommitProgress', AddWorktree = 'AddWorktree', + RenameWorktree = 'RenameWorktree', } interface IBasePopup { @@ -489,4 +490,9 @@ export type PopupDetail = type: PopupType.AddWorktree repository: Repository } + | { + type: PopupType.RenameWorktree + repository: Repository + worktreePath: string + } export type Popup = IBasePopup & PopupDetail From 383fe7d3a8d8a0fa3449a96c7857af62522f9428 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 18:02:00 +0900 Subject: [PATCH 13/31] Add RenameWorktreeDialog component Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../ui/worktrees/rename-worktree-dialog.tsx | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 app/src/ui/worktrees/rename-worktree-dialog.tsx 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 ( + + + + + + + + + + ) + } +} From c50ec72436695fbe103c74b9506aa55c8db846f4 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 18:02:07 +0900 Subject: [PATCH 14/31] Wire up rename worktree in context menu, dropdown, and app popup Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/app.tsx | 12 ++++++++++++ app/src/ui/toolbar/worktree-dropdown.tsx | 11 +++++++++++ .../ui/worktrees/worktree-list-item-context-menu.ts | 12 +++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index b35da44e63e..8b5278d0abf 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -198,6 +198,7 @@ import { 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' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2654,6 +2655,17 @@ export class App extends React.Component { /> ) } + case PopupType.RenameWorktree: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index 5debae5e988..ed43cc12aec 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -14,6 +14,7 @@ import { 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' interface IWorktreeDropdownProps { readonly dispatcher: Dispatcher @@ -115,12 +116,22 @@ export class WorktreeDropdown extends React.Component< 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 = async (path: string) => { const { repository } = this.props diff --git a/app/src/ui/worktrees/worktree-list-item-context-menu.ts b/app/src/ui/worktrees/worktree-list-item-context-menu.ts index 346aff8077f..dc837466871 100644 --- a/app/src/ui/worktrees/worktree-list-item-context-menu.ts +++ b/app/src/ui/worktrees/worktree-list-item-context-menu.ts @@ -7,16 +7,26 @@ 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, onRemoveWorktree } = config + 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), From 520e94944d539452f641b5bd0c541c74576a41cc Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 18:09:10 +0900 Subject: [PATCH 15/31] Add DeleteWorktree popup type and confirmation dialog Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/models/popup.ts | 6 ++ .../ui/worktrees/delete-worktree-dialog.tsx | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 app/src/ui/worktrees/delete-worktree-dialog.tsx diff --git a/app/src/models/popup.ts b/app/src/models/popup.ts index d55129eee53..5ff941f61f9 100644 --- a/app/src/models/popup.ts +++ b/app/src/models/popup.ts @@ -111,6 +111,7 @@ export enum PopupType { CommitProgress = 'CommitProgress', AddWorktree = 'AddWorktree', RenameWorktree = 'RenameWorktree', + DeleteWorktree = 'DeleteWorktree', } interface IBasePopup { @@ -495,4 +496,9 @@ export type PopupDetail = repository: Repository worktreePath: string } + | { + type: PopupType.DeleteWorktree + repository: Repository + worktreePath: string + } export type Popup = IBasePopup & PopupDetail 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..ff7b0a26f05 --- /dev/null +++ b/app/src/ui/worktrees/delete-worktree-dialog.tsx @@ -0,0 +1,74 @@ +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 } 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 }) + + try { + await removeWorktree(this.props.repository, this.props.worktreePath) + } catch (e) { + this.props.dispatcher.postError(e) + this.setState({ isDeleting: false }) + return + } + + this.props.onDismissed() + } +} From 101c6350fd8940523f893e51a17932666d1b0254 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 18:09:18 +0900 Subject: [PATCH 16/31] Wire up delete confirmation popup in dropdown and app Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/app.tsx | 12 ++++++++++++ app/src/ui/toolbar/worktree-dropdown.tsx | 22 ++++++++-------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 8b5278d0abf..8d2313668c3 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -199,6 +199,7 @@ 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 @@ -2666,6 +2667,17 @@ export class App extends React.Component { /> ) } + case PopupType.DeleteWorktree: { + return ( + + ) + } default: return assertNever(popup, `Unknown popup type: ${popup}`) } diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index ed43cc12aec..e9d7b66b162 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -6,11 +6,7 @@ import { ToolbarDropdown, DropdownState } from './dropdown' import { FoldoutType } from '../../lib/app-state' import { WorktreeEntry } from '../../models/worktree' import { WorktreeList } from '../worktrees/worktree-list' -import { - listWorktrees, - isLinkedWorktree, - removeWorktree, -} from '../../lib/git/worktree' +import { listWorktrees, isLinkedWorktree } 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' @@ -132,15 +128,13 @@ export class WorktreeDropdown extends React.Component< }) } - private onRemoveWorktree = async (path: string) => { - const { repository } = this.props - - try { - await removeWorktree(repository, path) - await this.fetchWorktrees() - } catch (e) { - log.error('Failed to remove worktree', e) - } + private onRemoveWorktree = (path: string) => { + this.props.dispatcher.closeFoldout(FoldoutType.Worktree) + this.props.dispatcher.showPopup({ + type: PopupType.DeleteWorktree, + repository: this.props.repository, + worktreePath: path, + }) } private onFilterTextChanged = (text: string) => { From 1d2c5e5892da9d206707d18d0b695aaec4be85df Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Thu, 12 Feb 2026 18:53:59 +0900 Subject: [PATCH 17/31] Show current worktree name in toolbar to match branch dropdown pattern Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/toolbar/worktree-dropdown.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index e9d7b66b162..88d9f2390c1 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -1,4 +1,5 @@ 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' @@ -170,14 +171,19 @@ export class WorktreeDropdown extends React.Component< 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' return ( Date: Mon, 16 Feb 2026 03:51:29 +0900 Subject: [PATCH 18/31] Add resizable worktree dropdown to match branch dropdown pattern - Wire worktreeDropdownWidth through app-state, app-store, and dispatcher - Wrap worktree toolbar button in Resizable with drag-to-resize support - Allocate toolbar space with branch-first priority to prevent layout overflow - Set foldout min-width independently from button constraints Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/lib/app-state.ts | 3 + app/src/lib/stores/app-store.ts | 78 +++++++++++++++---- app/src/ui/app.tsx | 1 + app/src/ui/dispatcher/dispatcher.ts | 8 ++ app/src/ui/toolbar/worktree-dropdown.tsx | 66 +++++++++++----- app/styles/ui/toolbar/_worktree-dropdown.scss | 7 +- 6 files changed, 126 insertions(+), 37 deletions(-) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index 6403b10478a..ec7172be3c8 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 diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index ac4fe444ba4..607418b70ac 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' @@ -539,6 +542,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 @@ -1104,6 +1108,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, @@ -2373,6 +2378,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 +2572,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 +2615,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 +2630,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, @@ -5763,6 +5785,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/ui/app.tsx b/app/src/ui/app.tsx index 8d2313668c3..ab805b995ed 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -3448,6 +3448,7 @@ export class App extends React.Component { onDropDownStateChanged={this.onWorktreeDropdownStateChanged} enableFocusTrap={enableFocusTrap} repositories={this.state.repositories} + worktreeDropdownWidth={this.state.worktreeDropdownWidth} /> ) } diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 1afa53ed573..82271b614d7 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -1050,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. diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index 88d9f2390c1..061af15e754 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -4,14 +4,16 @@ import { Dispatcher } from '../dispatcher' import * as octicons from '../octicons/octicons.generated' import { Repository } from '../../models/repository' import { ToolbarDropdown, DropdownState } from './dropdown' -import { FoldoutType } from '../../lib/app-state' +import { FoldoutType, IConstrainedValue } from '../../lib/app-state' import { WorktreeEntry } from '../../models/worktree' import { WorktreeList } from '../worktrees/worktree-list' -import { listWorktrees, isLinkedWorktree } from '../../lib/git/worktree' +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 @@ -20,12 +22,12 @@ interface IWorktreeDropdownProps { readonly onDropDownStateChanged: (state: DropdownState) => void readonly enableFocusTrap: boolean readonly repositories: ReadonlyArray + readonly worktreeDropdownWidth: IConstrainedValue } interface IWorktreeDropdownState { readonly worktrees: ReadonlyArray readonly filterText: string - readonly isCurrentRepoLinkedWorktree: boolean readonly worktreeAddedRepo: Repository | null } @@ -38,7 +40,6 @@ export class WorktreeDropdown extends React.Component< this.state = { worktrees: [], filterText: '', - isCurrentRepoLinkedWorktree: false, worktreeAddedRepo: null, } } @@ -53,21 +54,11 @@ export class WorktreeDropdown extends React.Component< const { repository } = this.props try { - const [worktrees, isLinked] = await Promise.all([ - listWorktrees(repository), - isLinkedWorktree(repository), - ]) - - this.setState({ - worktrees, - isCurrentRepoLinkedWorktree: isLinked, - }) + const worktrees = await listWorktrees(repository) + this.setState({ worktrees }) } catch (e) { log.error('Failed to fetch worktrees', e) - this.setState({ - worktrees: [], - isCurrentRepoLinkedWorktree: false, - }) + this.setState({ worktrees: [] }) } } @@ -138,6 +129,14 @@ export class WorktreeDropdown extends React.Component< }) } + 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 }) } @@ -154,7 +153,8 @@ export class WorktreeDropdown extends React.Component< onWorktreeClick={this.onWorktreeClick} filterText={this.state.filterText} onFilterTextChanged={this.onFilterTextChanged} - canCreateNewWorktree={false} + canCreateNewWorktree={true} + onCreateNewWorktree={this.onCreateNewWorktree} onWorktreeContextMenu={this.onWorktreeContextMenu} /> ) @@ -168,6 +168,14 @@ export class WorktreeDropdown extends React.Component< ) } + 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' @@ -177,7 +185,7 @@ export class WorktreeDropdown extends React.Component< : this.props.repository.name const description = __DARWIN__ ? 'Current Worktree' : 'Current worktree' - return ( + const toolbarDropdown = ( ) + + if (!enableResizingToolbarButtons()) { + return toolbarDropdown + } + + return ( + + {toolbarDropdown} + + ) } } diff --git a/app/styles/ui/toolbar/_worktree-dropdown.scss b/app/styles/ui/toolbar/_worktree-dropdown.scss index 786990880fe..5048269a426 100644 --- a/app/styles/ui/toolbar/_worktree-dropdown.scss +++ b/app/styles/ui/toolbar/_worktree-dropdown.scss @@ -1,12 +1,15 @@ .worktree-button { max-width: 250px; + &.resizable { + max-width: none; + width: 100%; + } + .toolbar-dropdown-button { - // Ensure the button doesn't get too wide max-width: 100%; } - // Ensure title and description truncate properly .title, .description { @include ellipsis; From 4205f5f8f187cb5780f0a61f1cce589e0a1e4ce1 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Mon, 16 Feb 2026 03:51:45 +0900 Subject: [PATCH 19/31] Add New Worktree button next to search in worktree dropdown - Render '+ New Worktree' via renderPostFilter alongside the filter input - Remove unused isCurrentRepoLinkedWorktree state field - Reorder members to place memoized getGroups before render helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/worktrees/worktree-list.tsx | 31 ++++++++++++++------------ app/styles/ui/_worktrees.scss | 3 +-- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/src/ui/worktrees/worktree-list.tsx b/app/src/ui/worktrees/worktree-list.tsx index 3720ca796d0..90bcb6682f0 100644 --- a/app/src/ui/worktrees/worktree-list.tsx +++ b/app/src/ui/worktrees/worktree-list.tsx @@ -5,6 +5,8 @@ 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' @@ -39,19 +41,6 @@ interface IWorktreeListProps { type WorktreeGroupIdentifier = 'main' | 'linked' export class WorktreeList extends React.Component { - private renderItem = (item: IWorktreeListItem, matches: IMatches) => { - return ( - - ) - } - private getGroups = memoizeOne((worktrees: ReadonlyArray) => { const groups: Array< IFilterListGroup @@ -87,6 +76,19 @@ export class WorktreeList extends React.Component { return groups }) + private renderItem = (item: IWorktreeListItem, matches: IMatches) => { + return ( + + ) + } + private renderGroupHeader = (identifier: WorktreeGroupIdentifier) => { const label = identifier === 'main' ? 'Main Worktree' : 'Linked Worktrees' return
{label}
@@ -101,7 +103,8 @@ export class WorktreeList extends React.Component { className="new-worktree-button" onClick={this.props.onCreateNewWorktree} > - New Worktree + + {__DARWIN__ ? 'New Worktree' : 'New worktree'} ) } diff --git a/app/styles/ui/_worktrees.scss b/app/styles/ui/_worktrees.scss index d712324fa99..f0b99ee4be8 100644 --- a/app/styles/ui/_worktrees.scss +++ b/app/styles/ui/_worktrees.scss @@ -54,8 +54,7 @@ } .new-worktree-button { - margin: var(--spacing); - width: calc(100% - (var(--spacing) * 2)); + flex-shrink: 0; } .no-items-found { From f21f8aa34ce0fbca050d81ce5285aace99cd8dda Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Mon, 16 Feb 2026 03:52:01 +0900 Subject: [PATCH 20/31] Replace title attribute with TooltippedContent in worktree list item Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/worktrees/worktree-list-item.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/ui/worktrees/worktree-list-item.tsx b/app/src/ui/worktrees/worktree-list-item.tsx index 4f2c7a1fa50..121c93f0a47 100644 --- a/app/src/ui/worktrees/worktree-list-item.tsx +++ b/app/src/ui/worktrees/worktree-list-item.tsx @@ -37,9 +37,15 @@ export class WorktreeListItem extends React.Component { {worktree.branch && ( -
+ {worktree.branch} -
+ )} ) From 748334b0354e72037776a6cf5c851ec8226a9409 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Mon, 16 Feb 2026 18:02:00 +0900 Subject: [PATCH 21/31] Fix worktree dropdown width to expand with foldout like branch dropdown Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/toolbar/worktree-dropdown.tsx | 8 +++++++- app/styles/ui/_worktrees.scss | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/ui/toolbar/worktree-dropdown.tsx b/app/src/ui/toolbar/worktree-dropdown.tsx index 061af15e754..d8f47f1e746 100644 --- a/app/src/ui/toolbar/worktree-dropdown.tsx +++ b/app/src/ui/toolbar/worktree-dropdown.tsx @@ -198,7 +198,13 @@ export class WorktreeDropdown extends React.Component< showDisclosureArrow={true} enableFocusTrap={enableFocusTrap} foldoutStyleOverrides={ - enableResizingToolbarButtons() ? { minWidth: 365 } : undefined + enableResizingToolbarButtons() + ? { + width: this.props.worktreeDropdownWidth.value, + maxWidth: this.props.worktreeDropdownWidth.max, + minWidth: 365, + } + : undefined } /> ) diff --git a/app/styles/ui/_worktrees.scss b/app/styles/ui/_worktrees.scss index f0b99ee4be8..4490e1bf2a4 100644 --- a/app/styles/ui/_worktrees.scss +++ b/app/styles/ui/_worktrees.scss @@ -4,7 +4,7 @@ height: 100%; display: flex; flex-direction: column; - width: 365px; + width: 100%; min-height: 0; .worktrees-list-item { From 9cdd797e9bc8a24ed8da74f11751df763b266da5 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Mon, 16 Feb 2026 18:02:09 +0900 Subject: [PATCH 22/31] Auto-switch to newly created worktree after creation Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/worktrees/add-worktree-dialog.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/ui/worktrees/add-worktree-dialog.tsx b/app/src/ui/worktrees/add-worktree-dialog.tsx index 41de2f538bc..e5b413c04f6 100644 --- a/app/src/ui/worktrees/add-worktree-dialog.tsx +++ b/app/src/ui/worktrees/add-worktree-dialog.tsx @@ -71,6 +71,13 @@ export class AddWorktreeDialog extends React.Component< return } + const { dispatcher } = this.props + const addedRepos = await dispatcher.addRepositories([path]) + + if (addedRepos.length > 0) { + await dispatcher.selectRepository(addedRepos[0]) + } + this.setState({ creating: false }) this.props.onDismissed() } From 546af41992c646cd43c755dca3d9fafc1993867d Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Mon, 16 Feb 2026 18:02:16 +0900 Subject: [PATCH 23/31] Hide linked worktrees from the repository list Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/lib/git/worktree.ts | 17 +++++++++++++++++ app/src/ui/app.tsx | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/lib/git/worktree.ts b/app/src/lib/git/worktree.ts index 2b981fd0bbf..2297ec55ebe 100644 --- a/app/src/lib/git/worktree.ts +++ b/app/src/lib/git/worktree.ts @@ -1,3 +1,5 @@ +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' @@ -133,6 +135,21 @@ export async function getMainWorktreePath( return main?.path ?? null } +/** + * Synchronously checks if a repository path is a linked worktree by examining + * whether `.git` is a file (linked worktree) or directory (main worktree). + */ +export function isLinkedWorktreeSync(repositoryPath: string): boolean { + 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/ui/app.tsx b/app/src/ui/app.tsx index ab805b995ed..c3e0bcc01b1 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -200,6 +200,7 @@ 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' +import { isLinkedWorktreeSync } from '../lib/git/worktree' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2982,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 && isLinkedWorktreeSync(r.path)) + ) return ( Date: Tue, 17 Feb 2026 01:27:13 +0900 Subject: [PATCH 24/31] Use RefNameTextBox in worktree creation dialog for branch name input Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/worktrees/add-worktree-dialog.tsx | 8 ++++---- app/styles/ui/_ref-name-text-box.scss | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/ui/worktrees/add-worktree-dialog.tsx b/app/src/ui/worktrees/add-worktree-dialog.tsx index e5b413c04f6..711c44e4658 100644 --- a/app/src/ui/worktrees/add-worktree-dialog.tsx +++ b/app/src/ui/worktrees/add-worktree-dialog.tsx @@ -4,6 +4,7 @@ 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' @@ -105,11 +106,10 @@ export class AddWorktreeDialog extends React.Component< - 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 { From 9369200494ebfe6e800bfd79c8e52e58c735440a Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Tue, 17 Feb 2026 01:27:20 +0900 Subject: [PATCH 25/31] Cache isLinkedWorktree as a lazy property on Repository Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/models/repository.ts | 10 ++++++++++ app/src/ui/app.tsx | 3 +-- 2 files changed, 11 insertions(+), 2 deletions(-) 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/ui/app.tsx b/app/src/ui/app.tsx index c3e0bcc01b1..2e77df18deb 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -200,7 +200,6 @@ 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' -import { isLinkedWorktreeSync } from '../lib/git/worktree' const MinuteInMilliseconds = 1000 * 60 const HourInMilliseconds = MinuteInMilliseconds * 60 @@ -2984,7 +2983,7 @@ 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 && isLinkedWorktreeSync(r.path)) + r => !(r instanceof Repository && r.isLinkedWorktree) ) return ( Date: Tue, 17 Feb 2026 01:57:33 +0900 Subject: [PATCH 26/31] Fix deleting the currently selected worktree by switching to main first Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../ui/worktrees/delete-worktree-dialog.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/app/src/ui/worktrees/delete-worktree-dialog.tsx b/app/src/ui/worktrees/delete-worktree-dialog.tsx index ff7b0a26f05..d7b2e7c0986 100644 --- a/app/src/ui/worktrees/delete-worktree-dialog.tsx +++ b/app/src/ui/worktrees/delete-worktree-dialog.tsx @@ -6,7 +6,7 @@ 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 } from '../../lib/git/worktree' +import { removeWorktree, getMainWorktreePath } from '../../lib/git/worktree' interface IDeleteWorktreeDialogProps { readonly repository: Repository @@ -61,10 +61,34 @@ export class DeleteWorktreeDialog extends React.Component< private onDeleteWorktree = async () => { this.setState({ isDeleting: true }) + const { repository, worktreePath, dispatcher } = this.props + const isDeletingCurrentWorktree = + normalizePath(repository.path) === normalizePath(worktreePath) + try { - await removeWorktree(this.props.repository, this.props.worktreePath) + 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) { - this.props.dispatcher.postError(e) + dispatcher.postError(e) this.setState({ isDeleting: false }) return } @@ -72,3 +96,7 @@ export class DeleteWorktreeDialog extends React.Component< this.props.onDismissed() } } + +function normalizePath(p: string): string { + return p.replace(/\/+$/, '') +} From a5040c1212c543b329c648874c459b50f53e10fc Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:31:14 +0100 Subject: [PATCH 27/31] Fix accelerator conflict This conflict prevents the app from opening on Linux --- app/src/main-process/menu/build-default-menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main-process/menu/build-default-menu.ts b/app/src/main-process/menu/build-default-menu.ts index 77ca8a18dee..167bba41afd 100644 --- a/app/src/main-process/menu/build-default-menu.ts +++ b/app/src/main-process/menu/build-default-menu.ts @@ -631,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 = ( From d5d61f2c7f9f4b2fa43df8e96df9b92fec1a1dba Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:35:27 +0100 Subject: [PATCH 28/31] Update worktree creation flow The previous flow required entering the new directory and optionally the branch name. However, the last directory fragment cannot contain spaces if the branch name is empty. The new flow requires for a PARENT directory (auto-populated) that can contain spaces and a mandatory branch name. This should be much simpler, as it only requires entering the branch name in a RefNameTextBox, which auto-converts spaces into dashes. --- app/src/ui/worktrees/add-worktree-dialog.tsx | 38 +++++++++++--------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/app/src/ui/worktrees/add-worktree-dialog.tsx b/app/src/ui/worktrees/add-worktree-dialog.tsx index 711c44e4658..506fb4afb25 100644 --- a/app/src/ui/worktrees/add-worktree-dialog.tsx +++ b/app/src/ui/worktrees/add-worktree-dialog.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import * as Path from 'path' import { Repository } from '../../models/repository' import { Dispatcher } from '../dispatcher' @@ -18,7 +19,7 @@ interface IAddWorktreeDialogProps { } interface IAddWorktreeDialogState { - readonly path: string + readonly parentDirPath: string readonly branchName: string readonly creating: boolean } @@ -31,14 +32,14 @@ export class AddWorktreeDialog extends React.Component< super(props) this.state = { - path: '', + parentDirPath: Path.dirname(props.repository.path), branchName: '', creating: false, } } - private onPathChanged = (path: string) => { - this.setState({ path }) + private onParentDirPathChanged = (parentDirPath: string) => { + this.setState({ parentDirPath }) } private onBranchNameChanged = (branchName: string) => { @@ -54,26 +55,27 @@ export class AddWorktreeDialog extends React.Component< return } - this.setState({ path }) + this.setState({ parentDirPath: path }) } private onSubmit = async () => { - const { path, branchName } = this.state + 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, path, { + await addWorktree(this.props.repository, worktreePath, { createBranch: branchName.length > 0 ? branchName : undefined, }) } catch (e) { - this.props.dispatcher.postError(e) + dispatcher.postError(e) this.setState({ creating: false }) return } - const { dispatcher } = this.props - const addedRepos = await dispatcher.addRepositories([path]) + const addedRepos = await dispatcher.addRepositories([worktreePath]) if (addedRepos.length > 0) { await dispatcher.selectRepository(addedRepos[0]) @@ -84,7 +86,11 @@ export class AddWorktreeDialog extends React.Component< } public render() { - const disabled = this.state.path.length === 0 || this.state.creating + const disabled = + !this.state.parentDirPath || + !this.state.branchName || + this.state.creating || + !Path.isAbsolute(this.state.parentDirPath) return ( From 759cec46a30233b1b0b8d3ad4a03714e9b49c04a Mon Sep 17 00:00:00 2001 From: Pol Rivero <65060696+pol-rivero@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:40:45 +0100 Subject: [PATCH 29/31] Auto-focus workspace name text box --- app/src/ui/worktrees/add-worktree-dialog.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/ui/worktrees/add-worktree-dialog.tsx b/app/src/ui/worktrees/add-worktree-dialog.tsx index 506fb4afb25..96b1d50abef 100644 --- a/app/src/ui/worktrees/add-worktree-dialog.tsx +++ b/app/src/ui/worktrees/add-worktree-dialog.tsx @@ -28,6 +28,8 @@ export class AddWorktreeDialog extends React.Component< IAddWorktreeDialogProps, IAddWorktreeDialogState > { + private branchNameTextBoxRef = React.createRef() + public constructor(props: IAddWorktreeDialogProps) { super(props) @@ -38,6 +40,10 @@ export class AddWorktreeDialog extends React.Component< } } + public componentDidMount() { + this.branchNameTextBoxRef.current?.focus() + } + private onParentDirPathChanged = (parentDirPath: string) => { this.setState({ parentDirPath }) } @@ -116,6 +122,7 @@ export class AddWorktreeDialog extends React.Component< label={__DARWIN__ ? 'New Workspace Name' : 'New workspace name'} initialValue="" onValueChange={this.onBranchNameChanged} + ref={this.branchNameTextBoxRef} /> From fd1e7c0c639b1bc18d563a11b34db231614200e6 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Wed, 18 Feb 2026 16:58:49 +0900 Subject: [PATCH 30/31] Add showWorktrees preference to state and store layer Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/lib/app-state.ts | 3 +++ app/src/lib/stores/app-store.ts | 13 +++++++++++++ app/src/ui/dispatcher/dispatcher.ts | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/app/src/lib/app-state.ts b/app/src/lib/app-state.ts index ec7172be3c8..1d039824300 100644 --- a/app/src/lib/app-state.ts +++ b/app/src/lib/app-state.ts @@ -313,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 diff --git a/app/src/lib/stores/app-store.ts b/app/src/lib/stores/app-store.ts index 607418b70ac..4f0fc4d4bde 100644 --- a/app/src/lib/stores/app-store.ts +++ b/app/src/lib/stores/app-store.ts @@ -445,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 @@ -607,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 @@ -715,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, @@ -1151,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, @@ -4044,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 diff --git a/app/src/ui/dispatcher/dispatcher.ts b/app/src/ui/dispatcher/dispatcher.ts index 82271b614d7..990574e60a4 100644 --- a/app/src/ui/dispatcher/dispatcher.ts +++ b/app/src/ui/dispatcher/dispatcher.ts @@ -2874,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) } From d628076eb84f3908048a08a88e6f9df5cbb18bd1 Mon Sep 17 00:00:00 2001 From: Jeon Suyeol Date: Wed, 18 Feb 2026 16:58:55 +0900 Subject: [PATCH 31/31] Add worktrees visibility toggle to Appearance preferences Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- app/src/ui/app.tsx | 5 +++++ app/src/ui/preferences/appearance.tsx | 29 ++++++++++++++++++++++++++ app/src/ui/preferences/preferences.tsx | 13 ++++++++++++ 3 files changed, 47 insertions(+) diff --git a/app/src/ui/app.tsx b/app/src/ui/app.tsx index 2e77df18deb..b54d64067c8 100644 --- a/app/src/ui/app.tsx +++ b/app/src/ui/app.tsx @@ -1632,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} @@ -3434,6 +3435,10 @@ export class App extends React.Component { return null } + if (!this.state.showWorktrees) { + return null + } + const currentFoldout = this.state.currentFoldout const isOpen = 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) }