Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions openspec/changes/worktree-name-search/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

A repo with many worktrees makes the sidebar long; the "Starred" toggle narrows it only to worktrees starred beforehand, so finding one by its branch name means scrolling.

## What Changes

- Web: a text box in the sidebar header, between the title and the "Starred" toggle, listing only the worktrees whose label contains its text (trimmed, ignoring case), together with the "Starred" toggle; the selected worktree stays listed. A × beside it, shown while it holds text, empties it. Each repo keeps its own text, stored per browser across reloads (`wtd.searches`). A sidebar too narrow for one header row puts the box on its own row.
- Web: a setting of the page the browser refuses to store (storage full or blocked) still applies, and the page shows that saving failed, instead of the handler throwing.

## Capabilities

### New Capabilities

### Modified Capabilities
- `web`: worktree name search.

## Impact

- Code: `src/web/ui/{sidebar.ts,view.ts,app.tsx}`, `src/web/main/styles.css`.
- Architecture, wire, upgrade: no change.
- Tests: `src/web/ui/sidebar.test.ts`; `test/e2e/sidebar.spec.ts` (search per repo, persistence, ×, combined with "Starred").
28 changes: 28 additions & 0 deletions openspec/changes/worktree-name-search/specs/web/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## ADDED Requirements

### Requirement: Worktree name search
The sidebar header SHALL hold, between its title and the checked filter's toggle, a text box for the selected repo: while it holds text, the sidebar SHALL list only the worktrees whose label contains that text, trimmed and ignoring case, that the checked filter also lists, and the selected worktree. While it holds text, a control beside it SHALL empty it. Each repo SHALL keep its own text, stored per browser across reloads; a repo without one SHALL list every worktree the checked filter lists. The text SHALL play no part in which worktree is selected without a stored selection. When the browser refuses to store a setting of the page, the change SHALL still apply and the page SHALL show that saving failed.

#### Scenario: Search narrows the list
- **WHEN** a repo has worktrees on `feature/login`, `fix/Logout` and `docs`, `feature/login` is selected and the user types `LOG`
- **THEN** the sidebar lists `feature/login` and `fix/Logout` only

#### Scenario: Selected worktree stays listed
- **WHEN** the selected worktree's label does not contain the typed text
- **THEN** it stays listed and selected

#### Scenario: Search belongs to its repo and persists
- **WHEN** text is typed in one repo, another repo is selected and the page is reloaded
- **THEN** the other repo shows an empty box and every worktree its checked filter lists, and the first repo shows its text and lists only the matching worktrees and the selected one

#### Scenario: Clear control empties the search
- **WHEN** the user activates the clear control
- **THEN** the box is empty and focused, the clear control is gone, and every worktree the checked filter lists is listed, with the selected one, also after a reload

#### Scenario: Search with "checked only"
- **WHEN** the search is `feat` and the checked filter is "checked only"
- **THEN** only checked worktrees whose label contains `feat`, and the selected worktree, are listed

#### Scenario: Refused storage write
- **WHEN** the browser refuses to store the search and the user types text
- **THEN** the list is narrowed by the text and the page shows that saving its settings failed
9 changes: 9 additions & 0 deletions openspec/changes/worktree-name-search/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## 1. Web

- [x] 1.1 `searchOf`, `withSearch`, `matchesSearch` and the search in `visibleWorktrees` (not in `defaultWorktree`) with unit tests; `wtd.searches` and `setSearch` in the view; the text box and × in the sidebar header
- [x] 1.2 e2e: the search lists matching worktrees ignoring case, keeps the selected one, belongs to its repo, persists across a reload, × empties it; it applies together with "checked only"
- [x] 1.3 The view's settings writes report a refused write on the page; e2e: a refused write still narrows the list and shows the notice

## 2. Final gates

- [x] 2.1 `pnpm test` green (incl. Playwright); `pnpm lint` exits 0; `openspec validate worktree-name-search --strict` passes
53 changes: 52 additions & 1 deletion src/web/main/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ input:focus-visible,
overflow: hidden;
background: var(--panel);
border-right: 1px solid var(--border);
container-type: inline-size;
}

/* Over the sidebar's right border, so the terminal layer never covers it. */
Expand All @@ -145,13 +146,63 @@ input:focus-visible,

.sidebar-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px 10px;
min-height: 38px;
padding: 0 10px 0 13px;
padding: 6px 10px 6px 13px;
box-sizing: border-box;
border-bottom: 1px solid var(--border);
}

/* Too narrow for one row: the search takes its own row below. */
@container (max-width: 250px) {
.worktree-search {
order: 1;
flex-basis: 100%;
}
}

.worktree-search {
position: relative;
display: flex;
flex: 1 1 60px;
min-width: 0;
}

.worktree-search input {
width: 100%;
min-width: 0;
height: 22px;
box-sizing: border-box;
padding: 0 20px 0 7px;
font: inherit;
font-size: 12px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
}

.worktree-search input::placeholder {
color: var(--muted);
}

.worktree-search button {
position: absolute;
top: 0;
right: 0;
height: 22px;
padding: 0 6px;
line-height: 1;
color: var(--muted);
}

.worktree-search button:hover {
color: var(--text);
}

.sidebar-title {
color: var(--muted);
font-size: 11px;
Expand Down
38 changes: 38 additions & 0 deletions src/web/ui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,43 @@ const SidebarResizer = ({ view }: { view: View }) => {
);
};

/** The selected repo's worktree name search, with a button clearing it. */
const WorktreeSearch = ({ view }: { view: View }) => {
const input = useRef<HTMLInputElement>(null);
const search = view.search.value;
return (
<span class="worktree-search">
<input
ref={input}
type="text"
spellcheck={false}
autocomplete="off"
placeholder="Filter"
aria-label="Filter worktrees by name"
data-testid="worktree-search"
value={search}
onInput={(event) => {
view.setSearch(event.currentTarget.value);
}}
/>
{search !== '' && (
<button
type="button"
aria-label="Clear the worktree filter"
title="Clear the worktree filter"
data-testid="worktree-search-clear"
onClick={() => {
view.setSearch('');
input.current?.focus();
}}
>
×
</button>
)}
</span>
);
};

const Workspace = ({ client, manager, view }: AppProps) => {
const tab = view.tab.value;
const repo = view.repo.value;
Expand All @@ -977,6 +1014,7 @@ const Workspace = ({ client, manager, view }: AppProps) => {
<nav class="sidebar" style={{ width: px(view.sidebarWidth.value) }}>
<div class="sidebar-header">
<span class="sidebar-title">Worktrees</span>
<WorktreeSearch view={view} />
<label class="star-filter" title="Show starred worktrees only">
<span>Starred</span>
{/* Keyed by repo: switching repos mounts it in place, so only a click animates it. */}
Expand Down
84 changes: 80 additions & 4 deletions src/web/ui/sidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import {
defaultWorktree,
filterOf,
keptNote,
matchesSearch,
parseSidebarWidth,
removalReasons,
searchOf,
SIDEBAR_WIDTH,
sidebarEntries,
visibleWorktrees,
withFilter,
withSearch,
worktreeLabel,
type SidebarEntry,
} from './sidebar.js';
Expand Down Expand Up @@ -123,16 +126,57 @@ describe('visibleWorktrees', () => {
];

it('lists every entry with the all filter', () => {
expect(visibleWorktrees(entries, 'all', null)).toEqual(entries);
expect(visibleWorktrees(entries, 'all', '', null)).toEqual(entries);
});

it('lists only checked entries with the checked filter', () => {
expect(visibleWorktrees(entries, 'checked', null).map((e) => e.path)).toEqual(['/a', '/c']);
expect(visibleWorktrees(entries, 'checked', '', null).map((e) => e.path)).toEqual(['/a', '/c']);
});

it('keeps the selected entry listed with the checked filter', () => {
expect(visibleWorktrees(entries, 'checked', '/b').map((e) => e.path)).toEqual(['/a', '/b', '/c']);
expect(visibleWorktrees(entries, 'checked', '/d').map((e) => e.path)).toEqual(['/a', '/c', '/d']);
expect(visibleWorktrees(entries, 'checked', '', '/b').map((e) => e.path)).toEqual(['/a', '/b', '/c']);
expect(visibleWorktrees(entries, 'checked', '', '/d').map((e) => e.path)).toEqual(['/a', '/c', '/d']);
});

describe('with a name search', () => {
const named: SidebarEntry[] = [
{ path: '/m', label: 'main', prunable: false, gone: false, checked: false },
{ path: '/f', label: 'feature/Login', prunable: false, gone: false, checked: true },
{ path: '/g', label: 'fix/logout', prunable: false, gone: false, checked: false },
{ path: '/h', label: 'wt2 @ 0123456', prunable: false, gone: false, checked: true },
];
const paths = (filter: 'all' | 'checked', search: string, selected: string | null = null): string[] =>
visibleWorktrees(named, filter, search, selected).map((e) => e.path);

it('lists the entries whose label contains the search, ignoring case', () => {
expect(paths('all', 'log')).toEqual(['/f', '/g']);
expect(paths('all', 'LOGIN')).toEqual(['/f']);
expect(paths('all', '@ 0123')).toEqual(['/h']);
});

it('searches the directory labels of detached and gone worktrees', () => {
const built = sidebarEntries({ worktrees: [main, detached], terminals: [terminal(1, '/gone/wt3')], checked: [] });
expect(visibleWorktrees(built, 'all', 'WT2 @', null).map((e) => e.path)).toEqual([detached.path]);
expect(visibleWorktrees(built, 'all', 'wt3', null).map((e) => e.path)).toEqual(['/gone/wt3']);
});

it('ignores surrounding whitespace, and a blank search lists every entry', () => {
expect(paths('all', ' fix ')).toEqual(['/g']);
expect(paths('all', ' ')).toEqual(['/m', '/f', '/g', '/h']);
});

it('lists nothing when no label matches', () => {
expect(paths('all', 'nope')).toEqual([]);
});

it('applies together with the checked filter', () => {
expect(paths('checked', 'log')).toEqual(['/f']);
});

it('keeps the selected entry listed when it does not match', () => {
expect(paths('all', 'log', '/m')).toEqual(['/m', '/f', '/g']);
expect(paths('checked', 'log', '/g')).toEqual(['/f', '/g']);
});
});
});

Expand Down Expand Up @@ -199,6 +243,38 @@ describe('repo filters', () => {
});
});

describe('repo searches', () => {
it('searches nothing in a repo without an entry', () => {
expect(searchOf({ '0:/b': 'x' }, '0:/a')).toBe('');
});

it('sets one repo’s search without touching the others, keeping the text as typed', () => {
const searches = withSearch({ '0:/b': 'x' }, '0:/a', ' Feat ');
expect(searches).toEqual({ '0:/a': ' Feat ', '0:/b': 'x' });
expect(searchOf(searches, '0:/a')).toBe(' Feat ');
expect(searchOf(searches, '1:/a')).toBe('');
});

it('replaces a repo’s search and drops its entry when emptied', () => {
expect(withSearch({ '0:/a': 'x', '0:/b': 'y' }, '0:/a', 'z')).toEqual({ '0:/a': 'z', '0:/b': 'y' });
expect(withSearch({ '0:/a': 'x', '0:/b': 'y' }, '0:/a', '')).toEqual({ '0:/b': 'y' });
});
});

describe('matchesSearch', () => {
it.each([
['a substring', 'feature/login', 'ture/lo', true],
['the whole label', 'main', 'main', true],
['another case', 'Feature/Login', 'feature/LOGIN', true],
['a trimmed search', 'main', ' ain\t', true],
['an empty search', 'main', '', true],
['a non-substring', 'main', 'mian', false],
['a search longer than the label', 'main', 'mains', false],
])('%s', (_name, label, search, expected) => {
expect(matchesSearch(label, search)).toBe(expected);
});
});

describe('removalReasons', () => {
const safe = { base: 'origin/main', ahead: 0, changes: 0, running: [] };

Expand Down
39 changes: 30 additions & 9 deletions src/web/ui/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,31 @@ export type WorktreeFilter = 'all' | 'checked';
/** Each repo's filter by repo key; a repo without an entry shows all. */
export type RepoFilters = Readonly<Record<string, string>>;

/** Each repo's name search by repo key; a repo without an entry searches nothing. */
export type RepoSearches = Readonly<Record<string, string>>;

/** `record` with the repo `key` set to `value`; null drops its entry. */
const withEntry = (record: Readonly<Record<string, string>>, key: string, value: string | null): Readonly<Record<string, string>> => {
const rest = Object.fromEntries(Object.entries(record).filter(([k]) => k !== key));
return value === null ? rest : { ...rest, [key]: value };
};

/** The filter of the repo `key`. */
export const filterOf = (filters: RepoFilters, key: string): WorktreeFilter => (filters[key] === 'checked' ? 'checked' : 'all');

/** `filters` with the repo `key` set to `filter`; showing all drops its entry. */
export const withFilter = (filters: RepoFilters, key: string, filter: WorktreeFilter): RepoFilters => {
const rest = Object.fromEntries(Object.entries(filters).filter(([k]) => k !== key));
return filter === 'all' ? rest : { ...rest, [key]: filter };
};
export const withFilter = (filters: RepoFilters, key: string, filter: WorktreeFilter): RepoFilters =>
withEntry(filters, key, filter === 'all' ? null : filter);

/** The name search of the repo `key`. */
export const searchOf = (searches: RepoSearches, key: string): string => searches[key] ?? '';

/** `searches` with the repo `key` set to `search`; an empty search drops its entry. */
export const withSearch = (searches: RepoSearches, key: string, search: string): RepoSearches =>
withEntry(searches, key, search === '' ? null : search);

/** Whether `label` contains `search`, trimmed, ignoring case; a blank search matches every label. */
export const matchesSearch = (label: string, search: string): boolean => label.toLowerCase().includes(search.trim().toLowerCase());

/** Sidebar width bounds and default, in pixels. */
export const SIDEBAR_WIDTH = { min: 160, max: 640, initial: 260 } as const;
Expand Down Expand Up @@ -58,13 +75,17 @@ export const sidebarEntries = (state: {
return entries;
};

/** The entries the filter shows; the selected entry is always shown. */
export const visibleWorktrees = (entries: readonly SidebarEntry[], filter: WorktreeFilter, selected: string | null): SidebarEntry[] =>
filter === 'all' ? [...entries] : entries.filter((e) => e.checked || e.path === selected);
/** The entries the filter and the name search show; the selected entry is always shown. */
export const visibleWorktrees = (
entries: readonly SidebarEntry[],
filter: WorktreeFilter,
search: string,
selected: string | null,
): SidebarEntry[] => entries.filter((e) => e.path === selected || ((filter === 'all' || e.checked) && matchesSearch(e.label, search)));

/** The worktree selected without a stored selection: the first the filter shows, else the first entry. */
/** The worktree selected without a stored selection: the first the filter shows, else the first entry; the search plays no part. */
export const defaultWorktree = (entries: readonly SidebarEntry[], filter: WorktreeFilter): string | null =>
(visibleWorktrees(entries, filter, null)[0] ?? entries[0])?.path ?? null;
(visibleWorktrees(entries, filter, '', null)[0] ?? entries[0])?.path ?? null;

/** What the daemon reported deleting a worktree would lose. */
export interface RemovalRisks {
Expand Down
Loading
Loading