Files
OpenQode/bin/ui/components/FilePreviewTabs.mjs

131 lines
5.0 KiB
JavaScript

import React, { useEffect, useMemo, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import path from 'path';
const h = React.createElement;
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
const renderTabTitle = (tab, maxLen) => {
const base = tab.title || path.basename(tab.path || '') || 'untitled';
if (base.length <= maxLen) return base;
return base.slice(0, Math.max(1, maxLen - 1)) + '…';
};
const FilePreviewTabs = ({
tabs = [],
activeId = null,
onActivate,
onClose,
isActive = false,
width = 80,
height = 10
}) => {
const activeTab = tabs.find(t => t.id === activeId) || tabs[0] || null;
const [scrollTop, setScrollTop] = useState(0);
useEffect(() => {
setScrollTop(0);
}, [activeId]);
const contentLines = useMemo(() => {
if (!activeTab?.content) return [];
return activeTab.content.replace(/\r\n/g, '\n').split('\n');
}, [activeTab?.content]);
const headerRows = 2;
const footerRows = 1;
const bodyRows = Math.max(3, height - headerRows - footerRows);
const maxScroll = Math.max(0, contentLines.length - bodyRows);
const safeScrollTop = clamp(scrollTop, 0, maxScroll);
useEffect(() => {
if (safeScrollTop !== scrollTop) setScrollTop(safeScrollTop);
}, [safeScrollTop, scrollTop]);
useInput((input, key) => {
if (!isActive) return;
if (!activeTab) return;
if (key.escape) {
return;
}
if (key.upArrow) setScrollTop(v => Math.max(0, v - 1));
if (key.downArrow) setScrollTop(v => Math.min(maxScroll, v + 1));
if (key.pageUp) setScrollTop(v => Math.max(0, v - bodyRows));
if (key.pageDown) setScrollTop(v => Math.min(maxScroll, v + bodyRows));
if (key.home) setScrollTop(0);
if (key.end) setScrollTop(maxScroll);
if (key.leftArrow) {
const idx = tabs.findIndex(t => t.id === activeTab.id);
const prev = idx > 0 ? tabs[idx - 1] : tabs[tabs.length - 1];
if (prev && typeof onActivate === 'function') onActivate(prev.id);
}
if (key.rightArrow) {
const idx = tabs.findIndex(t => t.id === activeTab.id);
const next = idx >= 0 && idx < tabs.length - 1 ? tabs[idx + 1] : tabs[0];
if (next && typeof onActivate === 'function') onActivate(next.id);
}
if (key.ctrl && input.toLowerCase() === 'w') {
if (typeof onClose === 'function') onClose(activeTab.id);
}
}, { isActive });
const tabRow = useMemo(() => {
if (tabs.length === 0) return '';
const pad = 1;
const maxTitleLen = Math.max(6, Math.floor(width / Math.max(1, tabs.length)) - 6);
const parts = tabs.map(t => {
const title = renderTabTitle(t, maxTitleLen);
const dirty = t.dirty ? '*' : '';
return (t.id === activeId ? `[${title}${dirty}]` : ` ${title}${dirty} `);
});
const joined = parts.join(' ');
const truncated = joined.length > width - pad ? joined.slice(0, Math.max(0, width - pad - 1)) + '…' : joined;
return truncated;
}, [tabs, activeId, width]);
const lineNoWidth = Math.max(4, String(safeScrollTop + bodyRows).length + 1);
const contentWidth = Math.max(10, width - lineNoWidth - 2);
const visible = contentLines.slice(safeScrollTop, safeScrollTop + bodyRows);
return h(Box, {
flexDirection: 'column',
width,
height,
borderStyle: 'single',
borderColor: isActive ? 'cyan' : 'gray'
},
h(Box, { paddingX: 1, justifyContent: 'space-between' },
h(Text, { color: 'cyan', bold: true }, 'Files'),
h(Text, { color: 'gray', dimColor: true }, isActive ? '↑↓ scroll ←→ tabs Ctrl+W close Esc focus chat' : 'Ctrl+O open Ctrl+Shift+F search Tab focus')
),
h(Box, { paddingX: 1 },
h(Text, { color: 'white', wrap: 'truncate-end' }, tabRow || '(no tabs)')
),
h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
activeTab ? visible.map((line, i) => {
const lineNo = safeScrollTop + i + 1;
const no = String(lineNo).padStart(lineNoWidth - 1) + ' ';
return h(Box, { key: `${activeTab.id}:${lineNo}`, width: '100%' },
h(Text, { color: 'gray', dimColor: true }, no),
h(Text, { color: 'white', wrap: 'truncate-end' }, (line || '').slice(0, contentWidth))
);
}) : h(Text, { color: 'gray', dimColor: true }, 'Open a file to preview it here.')
),
h(Box, { paddingX: 1, justifyContent: 'space-between' },
h(Text, { color: 'gray', dimColor: true, wrap: 'truncate' }, activeTab?.relPath ? activeTab.relPath : ''),
activeTab ? h(Text, { color: 'gray', dimColor: true }, `${safeScrollTop + 1}-${Math.min(contentLines.length, safeScrollTop + bodyRows)} / ${contentLines.length}`) : null
)
);
};
export default FilePreviewTabs;