diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0e2c1e29a..7e3db4e3a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -135,6 +135,7 @@ function App(): React.JSX.Element { const groupBy = useAppStore((s) => s.groupBy) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) @@ -330,6 +331,7 @@ function App(): React.JSX.Element { groupBy: 'none', sortBy: 'recent', showActiveOnly: false, + hideDefaultBranchWorkspace: false, filterRepoIds: [], collapsedGroups: [], uiZoomLevel: 0, @@ -460,6 +462,7 @@ function App(): React.JSX.Element { groupBy, sortBy, showActiveOnly, + hideDefaultBranchWorkspace, filterRepoIds }) }, 150) @@ -472,6 +475,7 @@ function App(): React.JSX.Element { groupBy, sortBy, showActiveOnly, + hideDefaultBranchWorkspace, filterRepoIds ]) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index cf62333d8..88b330657 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -16,6 +16,7 @@ import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/gith import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' +import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { cn } from '@/lib/utils' import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status' @@ -157,6 +158,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace) const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query) @@ -176,7 +178,24 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hasQuery = deferredQuery.trim().length > 0 const sortedWorktrees = useMemo(() => { - const visibleWorktrees = allWorktrees.filter((worktree) => !worktree.isArchived) + const visibleWorktrees = allWorktrees.filter((worktree) => { + if (worktree.isArchived) { + return false + } + // Why: keep the jump palette aligned with the sidebar. If the user + // opted to hide the default-branch workspace, surfacing it here via + // Cmd+J would reintroduce the entry they asked to remove. + // Drift warning: this check must stay in lockstep with the sidebar's + // filter in computeVisibleWorktreeIds (visible-worktrees.ts). Both + // surfaces share isDefaultBranchWorkspace so the predicate can't drift, + // but adding a new filter axis (e.g. a second toggle) here would need + // the matching change in the sidebar pipeline — otherwise Cmd+J and + // the sidebar will show different lists. + if (hideDefaultBranchWorkspace && isDefaultBranchWorkspace(worktree)) { + return false + } + return true + }) // Why: on empty query, show pure recency (matches sidebar's 'recent' sort // rationale in smart-sort.ts) so Cmd+J is a predictable "jump back to what // I was just on" surface. Typing swaps in smart-sort to rank matches. @@ -188,11 +207,15 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { (a, b) => b.lastActivityAt - a.lastActivityAt || a.displayName.localeCompare(b.displayName) ) - }, [allWorktrees, tabsByWorktree, repoMap, prCache, hasQuery]) + }, [allWorktrees, tabsByWorktree, repoMap, prCache, hasQuery, hideDefaultBranchWorkspace]) const browserSortedWorktrees = useMemo(() => { // Why: browser-tab search is explicitly cross-worktree, so it must keep - // indexing live browser pages even when their owning worktree is archived. + // indexing live browser pages even when their owning worktree is archived + // or hidden by the default-branch-workspace setting. A user who opened a + // tab on the default-branch worktree before toggling hide-on should still + // be able to Cmd+J back to it — the setting hides the *workspace row*, + // not the browser tabs that live inside it. return sortWorktreesSmart(allWorktrees, tabsByWorktree, repoMap, prCache) }, [allWorktrees, tabsByWorktree, repoMap, prCache]) diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 6de1675a4..f9068f14f 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import { Activity, ListFilter, FolderPlus, X } from 'lucide-react' +import { Activity, GitBranch, ListFilter, FolderPlus, X } from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { @@ -17,6 +17,8 @@ import RepoDotLabel from '@/components/repo/RepoDotLabel' const SidebarFilter = React.memo(function SidebarFilter() { const showActiveOnly = useAppStore((s) => s.showActiveOnly) const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) + const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -37,14 +39,19 @@ const SidebarFilter = React.memo(function SidebarFilter() { () => setShowActiveOnly(!showActiveOnly), [showActiveOnly, setShowActiveOnly] ) + const handleToggleHideDefaultBranch = useCallback( + () => setHideDefaultBranchWorkspace(!hideDefaultBranchWorkspace), + [hideDefaultBranchWorkspace, setHideDefaultBranchWorkspace] + ) const canFilterRepos = repos.length > 1 // Why: derive from the current repos list so stale IDs in filterRepoIds // (e.g. lingering after a repo is removed) don't inflate the active-filter // count or falsely signal an applied filter. const selectedRepos = canFilterRepos ? repos.filter((r) => filterRepoIds.includes(r.id)) : [] const hasRepoFilter = selectedRepos.length > 0 - const hasAnyFilter = showActiveOnly || hasRepoFilter - const activeFilterCount = (showActiveOnly ? 1 : 0) + selectedRepos.length + const hasAnyFilter = showActiveOnly || hideDefaultBranchWorkspace || hasRepoFilter + const activeFilterCount = + (showActiveOnly ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedRepos.length return ( @@ -79,7 +86,6 @@ const SidebarFilter = React.memo(function SidebarFilter() { - Status Active only + event.preventDefault()} + > + + Hide default branch + {canFilterRepos && ( <> @@ -110,6 +124,7 @@ const SidebarFilter = React.memo(function SidebarFilter() { { setShowActiveOnly(false) + setHideDefaultBranchWorkspace(false) setFilterRepoIds([]) }} > diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 2a2cf7060..e3045d9e2 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -28,7 +28,12 @@ import { buildRows, getGroupKeyForWorktree } from './worktree-list-groups' -import { computeVisibleWorktreeIds, setVisibleWorktreeIds } from './visible-worktrees' +import { + computeClearFilterActions, + computeVisibleWorktreeIds, + setVisibleWorktreeIds, + sidebarHasActiveFilters +} from './visible-worktrees' import { useModifierHint } from '@/hooks/useModifierHint' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -440,6 +445,7 @@ const WorktreeList = React.memo(function WorktreeList() { const groupBy = useAppStore((s) => s.groupBy) const sortBy = useAppStore((s) => s.sortBy) const showActiveOnly = useAppStore((s) => s.showActiveOnly) + const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const activeView = useAppStore((s) => s.activeView) @@ -632,6 +638,7 @@ const WorktreeList = React.memo(function WorktreeList() { tabsByWorktree, browserTabsByWorktree, activeWorktreeId, + hideDefaultBranchWorkspace, repoMap }) return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) @@ -639,6 +646,7 @@ const WorktreeList = React.memo(function WorktreeList() { filterRepoIds, showActiveOnly, activeWorktreeId, + hideDefaultBranchWorkspace, repoMap, tabsByWorktree, browserTabsByWorktree, @@ -721,14 +729,32 @@ const WorktreeList = React.memo(function WorktreeList() { [openModal] ) - const hasFilters = !!(showActiveOnly || filterRepoIds.length) + // Why: hideDefaultBranchWorkspace is counted as a filter here so the + // empty-sidebar escape hatch (Clear Filters button below) is reachable when + // it's the only reason the list is empty — otherwise a user whose only + // worktree is a default-branch row and who just toggled hide on would see + // "No worktrees found" with no way back short of reopening the filter menu. + const filterState = useMemo( + () => ({ showActiveOnly, filterRepoIds, hideDefaultBranchWorkspace }), + [showActiveOnly, filterRepoIds, hideDefaultBranchWorkspace] + ) + const hasFilters = sidebarHasActiveFilters(filterState) const setShowActiveOnly = useAppStore((s) => s.setShowActiveOnly) + const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const clearFilters = useCallback(() => { - setShowActiveOnly(false) - setFilterRepoIds([]) - }, [setShowActiveOnly, setFilterRepoIds]) + const actions = computeClearFilterActions(filterState) + if (actions.resetShowActiveOnly) { + setShowActiveOnly(false) + } + if (actions.resetFilterRepoIds) { + setFilterRepoIds([]) + } + if (actions.resetHideDefaultBranchWorkspace) { + setHideDefaultBranchWorkspace(false) + } + }, [setShowActiveOnly, setFilterRepoIds, setHideDefaultBranchWorkspace, filterState]) if (worktrees.length === 0) { return ( diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 160f0af22..c855e58bd 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -1,6 +1,24 @@ import { describe, expect, it } from 'vitest' -import { computeVisibleWorktreeIds } from './visible-worktrees' -import type { Repo, Worktree } from '../../../../shared/types' +import { + computeClearFilterActions, + computeVisibleWorktreeIds, + isDefaultBranchWorkspace, + sidebarHasActiveFilters +} from './visible-worktrees' +import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' + +function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} function makeWorktree(id: string, repoId = 'repo1'): Worktree { return { @@ -34,6 +52,16 @@ const repoMap = new Map([ badgeColor: '#000', addedAt: 0 } + ], + [ + 'repo2', + { + id: 'repo2', + path: '/repo2', + displayName: 'Repo 2', + badgeColor: '#111', + addedAt: 0 + } ] ]) @@ -47,6 +75,7 @@ describe('computeVisibleWorktreeIds', () => { tabsByWorktree: {}, browserTabsByWorktree: { [wt.id]: [{ id: 'browser-1' }] }, activeWorktreeId: null, + hideDefaultBranchWorkspace: false, repoMap }) @@ -62,9 +91,248 @@ describe('computeVisibleWorktreeIds', () => { tabsByWorktree: {}, browserTabsByWorktree: {}, activeWorktreeId: wt.id, + hideDefaultBranchWorkspace: false, repoMap }) expect(result).toEqual([wt.id]) }) + + it('hides branch-backed main worktrees when default branch workspaces are hidden', () => { + const main = makeWorktree('main') + const feature = makeWorktree('feature') + main.isMainWorktree = true + + const result = computeVisibleWorktreeIds({ repo1: [main, feature] }, [main.id, feature.id], { + filterRepoIds: [], + showActiveOnly: false, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + activeWorktreeId: main.id, + hideDefaultBranchWorkspace: true, + repoMap + }) + + expect(result).toEqual([feature.id]) + }) + + it('keeps folder-mode main worktrees visible when default branch workspaces are hidden', () => { + const folder = makeWorktree('folder') + folder.isMainWorktree = true + folder.branch = '' + + const result = computeVisibleWorktreeIds({ repo1: [folder] }, [folder.id], { + filterRepoIds: [], + showActiveOnly: false, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + activeWorktreeId: null, + hideDefaultBranchWorkspace: true, + repoMap + }) + + expect(result).toEqual([folder.id]) + }) + + it('hides branch-backed mains across every repo in a multi-repo workspace', () => { + const main1 = makeWorktree('main1', 'repo1') + main1.isMainWorktree = true + const feature1 = makeWorktree('feature1', 'repo1') + const main2 = makeWorktree('main2', 'repo2') + main2.isMainWorktree = true + const feature2 = makeWorktree('feature2', 'repo2') + + const result = computeVisibleWorktreeIds( + { repo1: [main1, feature1], repo2: [main2, feature2] }, + [main1.id, feature1.id, main2.id, feature2.id], + { + filterRepoIds: [], + showActiveOnly: false, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + activeWorktreeId: null, + hideDefaultBranchWorkspace: true, + repoMap + } + ) + + expect(result).toEqual([feature1.id, feature2.id]) + }) + + it('composes with showActiveOnly: the hidden main is dropped even if it is the active worktree', () => { + const main = makeWorktree('main') + main.isMainWorktree = true + const feature = makeWorktree('feature') + + // Why: verifies filter ordering — hide runs before showActiveOnly, so + // main doesn't slip back in via the "active worktree is always visible" + // exception that showActiveOnly grants. Feature stays because it has a + // live PTY. + const result = computeVisibleWorktreeIds({ repo1: [main, feature] }, [main.id, feature.id], { + filterRepoIds: [], + showActiveOnly: true, + tabsByWorktree: { [feature.id]: [makeTab('t1', feature.id, 'p1')] }, + browserTabsByWorktree: {}, + activeWorktreeId: main.id, + hideDefaultBranchWorkspace: true, + repoMap + }) + + expect(result).toEqual([feature.id]) + }) + + it('composes with filterRepoIds: hides mains only within the selected repos', () => { + const main1 = makeWorktree('main1', 'repo1') + main1.isMainWorktree = true + const feature1 = makeWorktree('feature1', 'repo1') + const main2 = makeWorktree('main2', 'repo2') + main2.isMainWorktree = true + const feature2 = makeWorktree('feature2', 'repo2') + + // Why: the filterRepoIds=['repo1'] already drops everything in repo2, so + // to actually prove the hide filter is scoped to the selected repos we + // need to flip the situation — select repo2 instead. Only main2 should be + // dropped by hide; main1 survives because the repo filter has already + // removed it from consideration. + const result = computeVisibleWorktreeIds( + { repo1: [main1, feature1], repo2: [main2, feature2] }, + [main1.id, feature1.id, main2.id, feature2.id], + { + filterRepoIds: ['repo2'], + showActiveOnly: false, + tabsByWorktree: {}, + browserTabsByWorktree: {}, + activeWorktreeId: null, + hideDefaultBranchWorkspace: true, + repoMap + } + ) + + expect(result).toEqual([feature2.id]) + }) +}) + +describe('isDefaultBranchWorkspace', () => { + it('returns true for a branch-backed main worktree', () => { + const main = makeWorktree('main') + main.isMainWorktree = true + expect(isDefaultBranchWorkspace(main)).toBe(true) + }) + + it('returns false for folder-mode main worktrees (empty branch)', () => { + const folder = makeWorktree('folder') + folder.isMainWorktree = true + folder.branch = '' + expect(isDefaultBranchWorkspace(folder)).toBe(false) + }) + + it('returns false for non-main worktrees even on the default branch', () => { + const feature = makeWorktree('feature') + expect(isDefaultBranchWorkspace(feature)).toBe(false) + }) +}) + +describe('sidebarHasActiveFilters', () => { + it('returns false when no filters are active', () => { + expect( + sidebarHasActiveFilters({ + showActiveOnly: false, + filterRepoIds: [], + hideDefaultBranchWorkspace: false + }) + ).toBe(false) + }) + + it('returns true when only hideDefaultBranchWorkspace is active', () => { + // Why: regression guard for the empty-sidebar escape hatch. If hide is + // omitted from the filter union, a user whose only worktree is the + // default-branch row sees "No worktrees found" with no way back. + expect( + sidebarHasActiveFilters({ + showActiveOnly: false, + filterRepoIds: [], + hideDefaultBranchWorkspace: true + }) + ).toBe(true) + }) + + it('returns true when only showActiveOnly is active', () => { + expect( + sidebarHasActiveFilters({ + showActiveOnly: true, + filterRepoIds: [], + hideDefaultBranchWorkspace: false + }) + ).toBe(true) + }) + + it('returns true when only filterRepoIds is non-empty', () => { + expect( + sidebarHasActiveFilters({ + showActiveOnly: false, + filterRepoIds: ['repo1'], + hideDefaultBranchWorkspace: false + }) + ).toBe(true) + }) +}) + +describe('computeClearFilterActions', () => { + it('returns no-op actions when nothing is set', () => { + expect( + computeClearFilterActions({ + showActiveOnly: false, + filterRepoIds: [], + hideDefaultBranchWorkspace: false + }) + ).toEqual({ + resetShowActiveOnly: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false + }) + }) + + it('flags only hideDefaultBranchWorkspace for reset when it is the sole filter', () => { + // Why: verifies the empty-sidebar escape hatch actually clears the hide + // flag. A regression here would leave users stuck on "No worktrees found" + // because the only active filter would never clear. + expect( + computeClearFilterActions({ + showActiveOnly: false, + filterRepoIds: [], + hideDefaultBranchWorkspace: true + }) + ).toEqual({ + resetShowActiveOnly: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: true + }) + }) + + it('does not flag hideDefaultBranchWorkspace when it is already off', () => { + // Why: avoids issuing a pointless IPC write on every Clear Filters click + // in the common case where hide was never on. + const actions = computeClearFilterActions({ + showActiveOnly: true, + filterRepoIds: ['repo1'], + hideDefaultBranchWorkspace: false + }) + expect(actions.resetHideDefaultBranchWorkspace).toBe(false) + expect(actions.resetShowActiveOnly).toBe(true) + expect(actions.resetFilterRepoIds).toBe(true) + }) + + it('flags every active filter simultaneously', () => { + expect( + computeClearFilterActions({ + showActiveOnly: true, + filterRepoIds: ['repo1', 'repo2'], + hideDefaultBranchWorkspace: true + }) + ).toEqual({ + resetShowActiveOnly: true, + resetFilterRepoIds: true, + resetHideDefaultBranchWorkspace: true + }) + }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 09471b6c9..b1d7b9496 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -3,6 +3,65 @@ import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { useAppStore } from '@/store' import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' +/** + * Whether a worktree represents the repo's default-branch row that the + * "Hide Default Branch Workspace" setting targets. Folder-mode projects are + * main worktrees with branch === '' and are intentionally preserved. + * + * Why a shared helper: this predicate gates visibility in both the sidebar + * pipeline (computeVisibleWorktreeIds) and the Cmd+J jump palette. Keeping + * the definition in one place prevents the two surfaces from drifting. + */ +export function isDefaultBranchWorkspace(worktree: Worktree): boolean { + return worktree.isMainWorktree && worktree.branch.trim() !== '' +} + +/** Inputs describing every sidebar filter that can leave the list empty. */ +export type SidebarFilterState = { + showActiveOnly: boolean + filterRepoIds: readonly string[] + hideDefaultBranchWorkspace: boolean +} + +/** + * Whether at least one sidebar filter is active — drives the "Clear Filters" + * escape hatch in the empty-state message. Kept pure so it can be unit-tested + * alongside the sorting pipeline. + * + * Why include hideDefaultBranchWorkspace here: without it, a user whose only + * worktree is the default-branch row and who toggles hide-on would see the + * "No worktrees found" message with no in-sidebar recovery path. + */ +export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { + return state.showActiveOnly || state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace +} + +/** Describes which mutators the Clear Filters button must invoke, separated + * from the mutators themselves so the decision logic is testable. */ +export type ClearFilterActions = { + resetShowActiveOnly: boolean + resetFilterRepoIds: boolean + resetHideDefaultBranchWorkspace: boolean +} + +/** + * Determines which sidebar filters the Clear Filters button needs to reset. + * Returning an explicit action plan (rather than just calling the setters) + * keeps the pure decision separate from the impure mutations, so tests can + * verify the logic without mounting the component. + * + * Why reset only the ones that are set: keeps Clear Filters from churning + * UI state (and the debounced ui.set write-back) on every click when the + * flag was already off. + */ +export function computeClearFilterActions(state: SidebarFilterState): ClearFilterActions { + return { + resetShowActiveOnly: state.showActiveOnly, + resetFilterRepoIds: state.filterRepoIds.length > 0, + resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace + } +} + /** * Shared pure utility that computes the ordered list of visible (non-archived, * non-filtered) worktree IDs. Both the App-level Cmd+1–9 handler and @@ -22,6 +81,11 @@ export function computeVisibleWorktreeIds( tabsByWorktree: Record | null browserTabsByWorktree?: Record | null activeWorktreeId?: string | null + // Why required: every caller (WorktreeList, getVisibleWorktreeIds + // fallback, tests) reads the flag from the UI store. Making the field + // required prevents a future caller from silently dropping the filter by + // forgetting to pass it. + hideDefaultBranchWorkspace: boolean repoMap: Map } ): string[] { @@ -30,6 +94,10 @@ export function computeVisibleWorktreeIds( // Filter archived all = all.filter((w) => !w.isArchived) + if (opts.hideDefaultBranchWorkspace) { + all = all.filter((w) => !isDefaultBranchWorkspace(w)) + } + // Filter by repo if (opts.filterRepoIds.length > 0) { const selectedRepoIds = new Set(opts.filterRepoIds) @@ -141,6 +209,7 @@ export function getVisibleWorktreeIds(): string[] { tabsByWorktree: state.tabsByWorktree, browserTabsByWorktree: state.browserTabsByWorktree, activeWorktreeId: state.activeWorktreeId, + hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, repoMap }) } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index dfc3d9c33..017497cc4 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -97,6 +97,18 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showActiveOnly).toBe(true) }) + + it('restores the hide-default-branch filter from persisted UI state', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + hideDefaultBranchWorkspace: true + }) + ) + + expect(store.getState().hideDefaultBranchWorkspace).toBe(true) + }) }) describe('createUISlice settings navigation', () => { diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 9d876b3f1..37728199c 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -183,6 +183,8 @@ export type UISlice = { setSortBy: (s: UISlice['sortBy']) => void showActiveOnly: boolean setShowActiveOnly: (v: boolean) => void + hideDefaultBranchWorkspace: boolean + setHideDefaultBranchWorkspace: (v: boolean) => void filterRepoIds: string[] setFilterRepoIds: (ids: string[]) => void collapsedGroups: Set @@ -447,6 +449,9 @@ export const createUISlice: StateCreator = (set, get) showActiveOnly: false, setShowActiveOnly: (v) => set({ showActiveOnly: v }), + hideDefaultBranchWorkspace: false, + setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), + filterRepoIds: [], setFilterRepoIds: (ids) => set({ filterRepoIds: ids }), @@ -581,6 +586,7 @@ export const createUISlice: StateCreator = (set, get) // transient render detail. Restoring it on launch keeps the filtered // worktree list stable across restarts instead of silently widening it. showActiveOnly: ui.showActiveOnly, + hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, filterRepoIds: (ui.filterRepoIds ?? []).filter((repoId) => validRepoIds.has(repoId)), collapsedGroups: new Set(ui.collapsedGroups ?? []), uiZoomLevel: ui.uiZoomLevel ?? 0, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 2df5556fc..2f3d748e7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -239,6 +239,7 @@ export function getDefaultUIState(): PersistedUIState { groupBy: 'none', sortBy: 'recent', showActiveOnly: false, + hideDefaultBranchWorkspace: false, filterRepoIds: [], collapsedGroups: [], uiZoomLevel: 0, diff --git a/src/shared/types.ts b/src/shared/types.ts index 17b476c24..6575d94c9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1231,6 +1231,12 @@ export type PersistedUIState = { groupBy: 'none' | 'repo' | 'pr-status' sortBy: 'name' | 'smart' | 'recent' | 'repo' showActiveOnly: boolean + /** Hide the repo's original checked-out branch from workspace navigation + * (sidebar and Cmd+J jump palette). Folder-mode repos are unaffected — + * the predicate in visible-worktrees.ts excludes worktrees with an empty + * branch. Lives alongside showActiveOnly because both are user-facing + * sidebar filters reached through the same dropdown. */ + hideDefaultBranchWorkspace: boolean filterRepoIds: string[] collapsedGroups: string[] uiZoomLevel: number