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
17 changes: 14 additions & 3 deletions src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '../constants/statusIcons.js';
import {useSearchMode} from '../hooks/useSearchMode.js';
import {useDynamicLimit} from '../hooks/useDynamicLimit.js';
import {useAvailableLabelWidth} from '../hooks/useAvailableLabelWidth.js';
import {useGitStatus} from '../hooks/useGitStatus.js';
import {
type SessionItem,
Expand Down Expand Up @@ -184,6 +185,10 @@ const Dashboard: React.FC<DashboardProps> = ({
hasError: !!displayError,
});

// Room a row label may occupy; decides whether the session state tag gets
// its own aligned column or is appended to the branch name instead.
const availableLabelWidth = useAvailableLabelWidth();

// Git status polling for session worktrees
const enrichedWorktrees = useGitStatus(
baseSessionWorktrees,
Expand Down Expand Up @@ -361,7 +366,7 @@ const Dashboard: React.FC<DashboardProps> = ({
enrichedWorktrees.find(w => w.path === entry.worktree.path) ||
entry.worktree;
const stateData = entry.session.stateMutex.getSnapshot();
const status = ` [${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
const status = `[${getStatusDisplay(stateData.state, stateData.backgroundTaskCount, stateData.teamMemberCount)}]`;
const fullBranchName = wt.branch
? wt.branch.replace('refs/heads/', '')
: wt.path.split('/').pop() || 'detached';
Expand All @@ -379,7 +384,7 @@ const Dashboard: React.FC<DashboardProps> = ({
entry.session,
worktreeSessionCount > 1,
);
const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}${status}`;
const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}`;

let fileChanges = '';
let aheadBehind = '';
Expand All @@ -403,6 +408,7 @@ const Dashboard: React.FC<DashboardProps> = ({
worktree: wt,
session: entry.session,
baseLabel,
status,
searchableName: `${entry.projectName} :: ${fullBranchName}${isMain}`,
fileChanges,
aheadBehind,
Expand All @@ -413,6 +419,7 @@ const Dashboard: React.FC<DashboardProps> = ({
error: itemError,
lengths: {
base: stripAnsi(baseLabel).length,
status: stripAnsi(status).length,
fileChanges: stripAnsi(fileChanges).length,
aheadBehind: stripAnsi(aheadBehind).length,
parentBranch: stripAnsi(parentBranch).length,
Expand All @@ -423,7 +430,10 @@ const Dashboard: React.FC<DashboardProps> = ({
};
});

const columns = calculateColumnPositions(sessionWorkItems);
const columns = calculateColumnPositions(
sessionWorkItems,
availableLabelWidth,
);

if (!isSearchMode) {
menuItems.push({
Expand Down Expand Up @@ -554,6 +564,7 @@ const Dashboard: React.FC<DashboardProps> = ({
projectDisplayNames,
searchQuery,
isSearchMode,
availableLabelWidth,
]);

// Refresh handler
Expand Down
11 changes: 10 additions & 1 deletion src/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {projectManager} from '../services/projectManager.js';
import {RecentProject} from '../types/index.js';
import {useSearchMode} from '../hooks/useSearchMode.js';
import {useDynamicLimit} from '../hooks/useDynamicLimit.js';
import {useAvailableLabelWidth} from '../hooks/useAvailableLabelWidth.js';
import {
filterSessionItemsByQuery,
filterSessionItemsByState,
Expand Down Expand Up @@ -155,6 +156,10 @@ const Menu: React.FC<MenuProps> = ({
// Get worktree configuration for sorting
const worktreeConfig = configReader.getWorktreeConfig();

// Room a row label may occupy; decides whether the session state tag gets
// its own aligned column or is appended to the branch name instead.
const availableLabelWidth = useAvailableLabelWidth();

useEffect(() => {
let cancelled = false;

Expand Down Expand Up @@ -263,7 +268,10 @@ const Menu: React.FC<MenuProps> = ({
const items = prepareSessionItems(worktrees, sessions, {
sortByLastSession: worktreeConfig.sortByLastSession,
});
const columnPositions = calculateColumnPositions(items);
const columnPositions = calculateColumnPositions(
items,
availableLabelWidth,
);

// Filter session items based on search query, matching the name shown in
// the menu (branch name, " (main)", and session name) plus the path, then
Expand Down Expand Up @@ -454,6 +462,7 @@ const Menu: React.FC<MenuProps> = ({
autoApprovalToggleCounter,
sessionManager,
worktreeConfig.sortByLastSession,
availableLabelWidth,
]);

// Handle hotkeys
Expand Down
20 changes: 20 additions & 0 deletions src/hooks/useAvailableLabelWidth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {useStdout} from 'ink';

// Columns a list row spends before the assembled label: the SelectInput
// indicator ("❯ " or two spaces) plus the number prefix ("0 ❯ " / " ❯ ").
const ROW_PREFIX_WIDTH = 6;
// Terminal width assumed when stdout reports none (e.g. a non-TTY test stream).
const FALLBACK_TERMINAL_WIDTH = 80;

/**
* Number of terminal columns a worktree/session row label may occupy.
* Passed to calculateColumnPositions, which drops the aligned session-state
* column when the resulting layout would not fit in it.
*/
export function useAvailableLabelWidth(): number {
const {stdout} = useStdout();
return Math.max(
0,
(stdout.columns || FALLBACK_TERMINAL_WIDTH) - ROW_PREFIX_WIDTH,
);
}
2 changes: 2 additions & 0 deletions src/utils/filterByQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ const makeItem = (searchableName: string, path: string): SessionItem => ({
hasSession: false,
} as Worktree,
baseLabel: searchableName,
status: '',
searchableName,
fileChanges: '',
aheadBehind: '',
parentBranch: '',
lastCommitDate: '',
lengths: {
base: 0,
status: 0,
fileChanges: 0,
aheadBehind: 0,
parentBranch: 0,
Expand Down
85 changes: 79 additions & 6 deletions src/utils/worktreeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
calculateColumnPositions,
assembleSessionLabel,
isDeletableWorktree,
type SessionItem,
} from './worktreeUtils.js';
import {Worktree, Session} from '../types/index.js';
import {execSync} from 'child_process';
Expand Down Expand Up @@ -266,9 +267,12 @@ describe('prepareSessionItems', () => {
expect(items[0]?.baseLabel).toBe('feature/test-branch');
});

it('should include session status in label', () => {
it('should expose the session status separately from the name', () => {
const items = prepareSessionItems([mockWorktree], [mockSession]);
expect(items[0]?.baseLabel).toContain('[○ Idle]');
// The status tag is its own field so it can be rendered as an aligned
// column; it must not be baked into the name portion.
expect(items[0]?.status).toBe('[○ Idle]');
expect(items[0]?.baseLabel).toBe('feature/test-branch');
});

it('should mark main worktree', () => {
Expand Down Expand Up @@ -380,10 +384,9 @@ describe('prepareSessionItems', () => {
},
],
);
// Order must be: branch, dir suffix, (no main), session suffix, status.
expect(items[0]?.baseLabel).toMatch(
/^feature\/foo @ foo-api: lab \[.*Idle.*\]$/,
);
// Order must be: branch, dir suffix, (no main), session suffix.
expect(items[0]?.baseLabel).toBe('feature/foo @ foo-api: lab');
expect(items[0]?.status).toMatch(/^\[.*Idle.*\]$/);
});

it('does not break column alignment when a dir suffix is appended', () => {
Expand Down Expand Up @@ -415,13 +418,15 @@ describe('column alignment', () => {
{
worktree: {} as Worktree,
baseLabel: 'feature/test-branch',
status: '',
searchableName: 'feature/test-branch',
fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
parentBranch: '',
lastCommitDate: '',
lengths: {
base: 19, // 'feature/test-branch'.length
status: 0,
fileChanges: 6, // '+10 -5'.length
aheadBehind: 5, // '↑2 ↓3'.length
parentBranch: 0,
Expand All @@ -431,13 +436,15 @@ describe('column alignment', () => {
{
worktree: {} as Worktree,
baseLabel: 'main',
status: '',
searchableName: 'main',
fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
aheadBehind: '\x1b[33m↑1\x1b[0m',
parentBranch: '',
lastCommitDate: '',
lengths: {
base: 4, // 'main'.length
status: 0,
fileChanges: 5, // '+2 -1'.length
aheadBehind: 2, // '↑1'.length
parentBranch: 0,
Expand Down Expand Up @@ -516,3 +523,69 @@ describe('isDeletableWorktree', () => {
).toBe(true);
});
});

describe('session status column', () => {
const makeItem = (
baseLabel: string,
status: string,
lastCommitDate: string,
): SessionItem => ({
worktree: {} as Worktree,
baseLabel,
status,
searchableName: baseLabel,
fileChanges: '',
aheadBehind: '',
parentBranch: '',
lastCommitDate,
lengths: {
base: baseLabel.length,
status: status.length,
fileChanges: 0,
aheadBehind: 0,
parentBranch: 0,
lastCommitDate: lastCommitDate.length,
},
});

const items = [
makeItem('feature/a-very-long-branch-name', '[○ Idle]', '1d ago'),
makeItem('main', '[● Busy]', '3w ago'),
];

it('starts every status tag at the same column, just left of the date', () => {
const columns = calculateColumnPositions(items, 120);
expect(columns.alignStatus).toBe(true);

const labels = items.map(item => assembleSessionLabel(item, columns));
for (const [index, label] of labels.entries()) {
expect(label.indexOf(items[index]!.status)).toBe(columns.status);
expect(label.indexOf(items[index]!.lastCommitDate)).toBe(
columns.lastCommitDate,
);
}
// The gap between the tag and the date is only the column padding.
expect(columns.lastCommitDate - columns.status).toBe('[○ Idle]'.length + 2);
});

it('falls back to appending the status to the name when too narrow', () => {
const columns = calculateColumnPositions(items, 40);
expect(columns.alignStatus).toBe(false);

expect(assembleSessionLabel(items[0]!, columns)).toContain(
'feature/a-very-long-branch-name [○ Idle]',
);
expect(assembleSessionLabel(items[1]!, columns)).toContain('main [● Busy]');
});

it('keeps the status next to the name on rows showing a git error', () => {
const errored: SessionItem = {
...makeItem('main', '[○ Idle]', ''),
error: '[git error]',
};
const columns = calculateColumnPositions([...items, errored], 120);
expect(assembleSessionLabel(errored, columns)).toBe(
'main [○ Idle] [git error]',
);
});
});
Loading
Loading