Release v1.01 Enhanced: Vi Control, TUI Gen5, Core Stability
This commit is contained in:
130
bin/ui/components/FilePreviewTabs.mjs
Normal file
130
bin/ui/components/FilePreviewTabs.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user