feat: hide default branch workspace (#1249)
Co-authored-by: Orca <help@stably.ai> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
parent
66d596fe11
commit
7edcdca699
|
|
@ -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
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
|
|
@ -79,7 +86,6 @@ const SidebarFilter = React.memo(function SidebarFilter() {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showActiveOnly}
|
||||
onCheckedChange={handleToggleActive}
|
||||
|
|
@ -88,6 +94,14 @@ const SidebarFilter = React.memo(function SidebarFilter() {
|
|||
<Activity className="size-3.5 text-muted-foreground" />
|
||||
Active only
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hideDefaultBranchWorkspace}
|
||||
onCheckedChange={handleToggleHideDefaultBranch}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
<GitBranch className="size-3.5 text-muted-foreground" />
|
||||
Hide default branch
|
||||
</DropdownMenuCheckboxItem>
|
||||
{canFilterRepos && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -110,6 +124,7 @@ const SidebarFilter = React.memo(function SidebarFilter() {
|
|||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setShowActiveOnly(false)
|
||||
setHideDefaultBranchWorkspace(false)
|
||||
setFilterRepoIds([])
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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<string, Repo>([
|
|||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, TerminalTab[]> | null
|
||||
browserTabsByWorktree?: Record<string, { id: string }[]> | 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, Repo>
|
||||
}
|
||||
): 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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
|
|
@ -447,6 +449,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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,
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ export function getDefaultUIState(): PersistedUIState {
|
|||
groupBy: 'none',
|
||||
sortBy: 'recent',
|
||||
showActiveOnly: false,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
filterRepoIds: [],
|
||||
collapsedGroups: [],
|
||||
uiZoomLevel: 0,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue