claude-code/components/TagTabs.tsx

139 lines
20 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { stringWidth } from '../ink/stringWidth.js';
import { Box, Text } from '../ink.js';
import { truncateToWidth } from '../utils/format.js';
// Constants for width calculations - derived from actual rendered strings
const ALL_TAB_LABEL = 'All';
const TAB_PADDING = 2; // Space before and after tab text: " {tab} "
const HASH_PREFIX_LENGTH = 1; // "#" prefix for non-All tabs
const LEFT_ARROW_PREFIX = '← ';
const RIGHT_HINT_WITH_COUNT_PREFIX = '→';
const RIGHT_HINT_SUFFIX = ' (tab to cycle)';
const RIGHT_HINT_NO_COUNT = '(tab to cycle)';
const MAX_OVERFLOW_DIGITS = 2; // Assume max 99 hidden tabs for width calculation
// Computed widths
const LEFT_ARROW_WIDTH = LEFT_ARROW_PREFIX.length + MAX_OVERFLOW_DIGITS + 1; // "← NN " with gap
const RIGHT_HINT_WIDTH_WITH_COUNT = RIGHT_HINT_WITH_COUNT_PREFIX.length + MAX_OVERFLOW_DIGITS + RIGHT_HINT_SUFFIX.length; // "→NN (tab to cycle)"
const RIGHT_HINT_WIDTH_NO_COUNT = RIGHT_HINT_NO_COUNT.length;
type Props = {
tabs: string[];
selectedIndex: number;
availableWidth: number;
showAllProjects?: boolean;
};
/**
* Calculate the display width of a tab
*/
function getTabWidth(tab: string, maxWidth?: number): number {
if (tab === ALL_TAB_LABEL) {
return ALL_TAB_LABEL.length + TAB_PADDING;
}
// For non-All tabs: " #{tag} " but truncate tag if needed
const tagWidth = stringWidth(tab);
const effectiveTagWidth = maxWidth ? Math.min(tagWidth, maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH) : tagWidth;
return Math.max(0, effectiveTagWidth) + TAB_PADDING + HASH_PREFIX_LENGTH;
}
/**
* Truncate a tag to fit within maxWidth, accounting for padding and hash prefix
*/
function truncateTag(tag: string, maxWidth: number): string {
// Available space for the tag text itself: maxWidth - " #" - " "
const availableForTag = maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH;
if (stringWidth(tag) <= availableForTag) {
return tag;
}
if (availableForTag <= 1) {
return tag.charAt(0);
}
return truncateToWidth(tag, availableForTag);
}
export function TagTabs({
tabs,
selectedIndex,
availableWidth,
showAllProjects = false
}: Props): React.ReactNode {
const resumeLabel = showAllProjects ? 'Resume (All Projects)' : 'Resume';
const resumeLabelWidth = resumeLabel.length + 1; // +1 for gap
// Calculate how much space we have for tabs (use worst-case hint width)
const rightHintWidth = Math.max(RIGHT_HINT_WIDTH_WITH_COUNT, RIGHT_HINT_WIDTH_NO_COUNT);
const maxTabsWidth = availableWidth - resumeLabelWidth - rightHintWidth - 2; // 2 for gaps
// Clamp selectedIndex to valid range
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, tabs.length - 1));
// Calculate width of each tab, with truncation for very long tags
const maxSingleTabWidth = Math.max(20, Math.floor(maxTabsWidth / 2)); // At least show half the space for one tab
const tabWidths = tabs.map(tab => getTabWidth(tab, maxSingleTabWidth));
// Find a window of tabs that fits, centered around selectedIndex
let startIndex = 0;
let endIndex = tabs.length;
// Calculate total width of all tabs
const totalTabsWidth = tabWidths.reduce((sum, w, i) => sum + w + (i < tabWidths.length - 1 ? 1 : 0), 0); // +1 for gaps between tabs
if (totalTabsWidth > maxTabsWidth) {
// Need to show a subset - account for left arrow when not at start
const effectiveMaxWidth = maxTabsWidth - LEFT_ARROW_WIDTH;
// Start with the selected tab
let windowWidth = tabWidths[safeSelectedIndex] ?? 0;
startIndex = safeSelectedIndex;
endIndex = safeSelectedIndex + 1;
// Expand window to include more tabs
while (startIndex > 0 || endIndex < tabs.length) {
const canExpandLeft = startIndex > 0;
const canExpandRight = endIndex < tabs.length;
if (canExpandLeft) {
const leftWidth = (tabWidths[startIndex - 1] ?? 0) + 1; // +1 for gap
if (windowWidth + leftWidth <= effectiveMaxWidth) {
startIndex--;
windowWidth += leftWidth;
continue;
}
}
if (canExpandRight) {
const rightWidth = (tabWidths[endIndex] ?? 0) + 1; // +1 for gap
if (windowWidth + rightWidth <= effectiveMaxWidth) {
endIndex++;
windowWidth += rightWidth;
continue;
}
}
break;
}
}
const hiddenLeft = startIndex;
const hiddenRight = tabs.length - endIndex;
const visibleTabs = tabs.slice(startIndex, endIndex);
const visibleIndices = visibleTabs.map((_, i_0) => startIndex + i_0);
return <Box flexDirection="row" gap={1}>
<Text color="suggestion">{resumeLabel}</Text>
{hiddenLeft > 0 && <Text dimColor>
{LEFT_ARROW_PREFIX}
{hiddenLeft}
</Text>}
{visibleTabs.map((tab_0, i_1) => {
const actualIndex = visibleIndices[i_1]!;
const isSelected = actualIndex === safeSelectedIndex;
const displayText = tab_0 === ALL_TAB_LABEL ? tab_0 : `#${truncateTag(tab_0, maxSingleTabWidth - TAB_PADDING)}`;
return <Text key={tab_0} backgroundColor={isSelected ? 'suggestion' : undefined} color={isSelected ? 'inverseText' : undefined} bold={isSelected}>
{' '}
{displayText}{' '}
</Text>;
})}
{hiddenRight > 0 ? <Text dimColor>
{RIGHT_HINT_WITH_COUNT_PREFIX}
{hiddenRight}
{RIGHT_HINT_SUFFIX}
</Text> : <Text dimColor>{RIGHT_HINT_NO_COUNT}</Text>}
</Box>;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInN0cmluZ1dpZHRoIiwiQm94IiwiVGV4dCIsInRydW5jYXRlVG9XaWR0aCIsIkFMTF9UQUJfTEFCRUwiLCJUQUJfUEFERElORyIsIkhBU0hfUFJFRklYX0xFTkdUSCIsIkxFRlRfQVJST1dfUFJFRklYIiwiUklHSFRfSElOVF9XSVRIX0NPVU5UX1BSRUZJWCIsIlJJR0hUX0hJTlRfU1VGRklYIiwiUklHSFRfSElOVF9OT19DT1VOVCIsIk1BWF9PVkVSRkxPV19ESUdJVFMiLCJMRUZUX0FSUk9XX1dJRFRIIiwibGVuZ3RoIiwiUklHSFRfSElOVF9XSURUSF9XSVRIX0NPVU5UIiwiUklHSFRfSElOVF9XSURUSF9OT19DT1VOVCIsIlByb3BzIiwidGFicyIsInNlbGVjdGVkSW5kZXgiLCJhdmFpbGFibGVXaWR0aCIsInNob3dBbGxQcm9qZWN0cyIsImdldFRhYldpZHRoIiwidGFiIiwibWF4V2lkdGgiLCJ0YWdXaWR0aCIsImVmZmVjdGl2ZVRhZ1dpZHRoIiwiTWF0aCIsIm1pbiIsIm1heCIsInRydW5jYXRlVGFnIiwidGFnIiwiYXZhaWxhYmxlRm9yVGFnIiwiY2hhckF0IiwiVGFnVGFicyIsIlJlYWN0Tm9kZSIsInJlc3VtZUxhYmVsIiwicmVzdW1lTGFiZWxXaWR0aCIsInJpZ2h0SGludFdpZHRoIiwibWF4VGFic1dpZHRoIiwic2FmZVNlbGVjdGVkSW5kZXgiLCJtYXhTaW5nbGVUYWJXaWR0aCIsImZsb29yIiwidGFiV2lkdGhzIiwibWFwIiwic3RhcnRJbmRleCIsImVuZEluZGV4IiwidG90YWxUYWJzV2lkdGgiLCJyZWR1Y2UiLCJzdW0iLCJ3IiwiaSIsImVmZmVjdGl2ZU1heFdpZHRoIiwid2luZG93V2lkdGgiLCJjYW5FeHBhbmRMZWZ0IiwiY2FuRXhwYW5kUmlnaHQiLCJsZWZ0V2lkdGgiLCJyaWdodFdpZHRoIiwiaGlkZGVuTGVmdCIsImhpZGRlblJpZ2h0IiwidmlzaWJsZVRhYnMiLCJzbGljZSIsInZpc2libGVJbmRpY2VzIiwiXyIsImFjdHVhbEluZGV4IiwiaXNTZWxlY3RlZCIsImRpc3BsYXlUZXh0IiwidW5kZWZpbmVkIl0sInNvdXJjZXMiOlsiVGFnVGFicy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgc3RyaW5nV2lkdGggfSBmcm9tICcuLi9pbmsvc3RyaW5nV2lkdGguanMnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyB0cnVuY2F0ZVRvV2lkdGggfSBmcm9tICcuLi91dGlscy9mb3JtYXQuanMnXG5cbi8vIENvbnN0YW50cyBmb3Igd2lkdGggY2FsY3VsYXRpb25zIC0gZGVyaXZlZCBmcm9tIGFjdHVhbCByZW5kZXJlZCBzdHJpbmdzXG5jb25zdCBBTExfVEFCX0xBQkVMID0gJ0FsbCdcbmNvbnN0IFRBQl9QQURESU5HID0gMiAvLyBTcGFjZSBiZWZvcmUgYW5kIGFmdGVyIHRhYiB0ZXh0OiBcIiB7dGFifSBcIlxuY29uc3QgSEFTSF9QUkVGSVhfTEVOR1RIID0gMSAvLyBcIiNcIiBwcmVmaXggZm9yIG5vbi1BbGwgdGFic1xuY29uc3QgTEVGVF9BUlJPV19QUkVGSVggPSAn4oaQICdcbmNvbnN0IFJJR0hUX0hJTlRfV0lUSF9DT1VOVF9QUkVGSVggPSAn4oaSJ1xuY29uc3QgUklHSFRfSElOVF9TVUZGSVggPSAnICh0YWIgdG8gY3ljbGUpJ1xuY29uc3QgUklHSFRfSElOVF9OT19DT1VOVCA9ICcodGFiIHRvIGN5Y2xlKSdcbmNvbnN0IE1BWF9PVkVSRkxPV19ESUdJVFMgPSAyIC8vIEFzc3VtZSBtYXggOTkgaGlkZGVuIHRhYnMgZm9yIHdpZHRoIGNhbGN1bGF0aW9uXG5cbi8vIENvbXB1dGVkIHdpZHRoc1xuY29uc3QgTEVGVF9BUlJPV19XSURUSCA9IExFRlRfQVJST1dfUFJFRklYLmxlbmd0aCArIE1BWF9PVkVSRkxPV19ESUdJVFMgKyAxIC8vIFwi4oaQIE5OIFwiIHdpdGggZ2FwXG5jb25zdCBSSUdIVF9ISU5UX1dJRFRIX1dJVEhfQ09VTlQgPVxuICBSSUdIVF9ISU5UX1dJVEhfQ09VTlRfUFJFRklYLmxlbmd0aCArXG4gIE1BWF9PVkVSRkxPV19ESUdJVFMgK1xuICBSSUdIVF9ISU5UX1NVRkZJWC5sZW5ndGggLy8gXCLihpJOTiAodGFiIHRvIGN5Y2xlKVwiXG5jb25zdCBSSUdIVF9ISU5UX1dJRFRIX05PX0NPVU5UID0gUklHSFRfSElOVF9OT19DT1VOVC5sZW5ndGhcblxudHlwZSBQcm9wcyA9IHtcbiAgdGFiczogc3RyaW5nW11cbiAgc2VsZWN0ZWRJbmRleDogbnVtYmVyXG4gIGF2YWlsYWJsZVdpZHRoOiBudW1iZXJcbiAgc2hvd0FsbFByb2plY3RzPzogYm9vbGVhblxufVxuXG4vKipcbiAqIENhbGN1bGF0ZSB0aGUgZGlzcGxheSB3aWR0aCBvZiBhIHRhYlxuICovXG5mdW5jdGlvbiBnZXRUYWJXaWR0aCh0YWI6IHN0cmluZywgbWF4V2lkdGg/OiBudW1iZXIpOiBudW1iZXIge1xuICBpZiAodGFiID09PSBBTExfVEFCX0xBQkVMKSB7XG4gICAgcmV0dXJuIEFMTF9UQUJfTEFCRUwubGVuZ3RoICsgVEFCX1BBRERJTkdcbiAgfVxuICAvLyBGb3Igbm9uLUFsbCB0YWJzOiBcIiAje3RhZ30gXCIgYnV0IHRydW5jYXRlIHRhZyBpZiBuZWVkZWRcbiAgY29uc3QgdGFnV2lkdGggPSBzdHJpbmdXaWR0aCh0YWIpXG4gIGNvbnN0IGVmZmVjdGl2ZVRhZ1dpZHRoID0gbWF4V2lkdGhcbiAgICA/IE1hdGgubWluKHRhZ1dpZHRoLCBtYXhXaWR0aCAtIFRBQl9QQURESU5HIC0gSEFTSF9QUkVGSVhfTEVOR1RIKVxuICAgIDogdGFnV2lkdGhcbiAgcmV0dXJuIE1hdGgubWF4KDAsIGVmZmVjdGl2ZVRhZ1dpZHRoKSArIFRBQl9QQURESU5HICsgSEFTSF9QUkVGSVhfTEVOR1RIXG59XG5cbi8qKlxuICogVHJ1bmNhdGUgYSB0YWcgdG8gZml0IHdpdGhpbiBtYXhXaWR0aCwgYWNjb3VudGluZyBmb3IgcGFkZGluZyBhbmQgaGFzaCBwcmVmaXhcbiAqL1xuZnVuY3Rpb24gdHJ1bmNhdGVUYWcodGFnOiBzdHJpbmcsIG1heFdpZHRoOiBudW1iZXIpOiBzdHJpbmcge1xuICAvLyBBdmFpbGFibGUgc3BhY2UgZm9yIHRoZSB0YWcgdGV4dCBpdHNlbGY6IG1heFdpZHRoIC0gXCIgI1wiIC0gXCIgXCJcbiAgY29uc3QgYXZhaWxhYmxlRm9yVGFnID0gbWF4V2lkdGggLSBUQUJfUEFERElORyAtIEhBU0hfUFJFRklYX0xFTkdUSFxuICBpZiAoc3RyaW5nV2lkdGgodGFnKSA8PSBhdmFpbGFibGVGb3JUYWcpIHtcbiAgICByZXR1cm4gdGFnXG4gIH1cbiAgaWYgKGF2YWlsYWJsZUZvclRhZyA