diffstalker 0.1.7 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +8 -0
- package/CHANGELOG.md +36 -0
- package/bun.lock +89 -306
- package/dist/App.js +895 -520
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +632 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +221 -86
- package/dist/git/diff.js +4 -0
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +68 -53
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +195 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/FileFinder.js +232 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +238 -0
- package/dist/ui/widgets/DiffView.js +281 -0
- package/dist/ui/widgets/ExplorerContent.js +89 -0
- package/dist/ui/widgets/ExplorerView.js +204 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +50 -0
- package/dist/ui/widgets/Header.js +68 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/wordDiff.js +50 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +14 -12
- package/dist/components/BaseBranchPicker.js +0 -60
- package/dist/components/BottomPane.js +0 -101
- package/dist/components/CommitPanel.js +0 -58
- package/dist/components/CompareListView.js +0 -110
- package/dist/components/ExplorerContentView.js +0 -80
- package/dist/components/ExplorerView.js +0 -37
- package/dist/components/FileList.js +0 -131
- package/dist/components/Footer.js +0 -6
- package/dist/components/Header.js +0 -107
- package/dist/components/HistoryView.js +0 -21
- package/dist/components/HotkeysModal.js +0 -108
- package/dist/components/Modal.js +0 -19
- package/dist/components/ScrollableList.js +0 -125
- package/dist/components/ThemePicker.js +0 -42
- package/dist/components/TopPane.js +0 -14
- package/dist/components/UnifiedDiffView.js +0 -115
- package/dist/hooks/useCommitFlow.js +0 -66
- package/dist/hooks/useCompareState.js +0 -123
- package/dist/hooks/useExplorerState.js +0 -248
- package/dist/hooks/useGit.js +0 -156
- package/dist/hooks/useHistoryState.js +0 -62
- package/dist/hooks/useKeymap.js +0 -167
- package/dist/hooks/useLayout.js +0 -154
- package/dist/hooks/useMouse.js +0 -87
- package/dist/hooks/useTerminalSize.js +0 -20
- package/dist/hooks/useWatcher.js +0 -137
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -209
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from 'react';
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
4
|
-
import { ScrollableList } from './ScrollableList.js';
|
|
5
|
-
import { buildExplorerContentRows, wrapExplorerContentRows, getExplorerContentRowCount, getExplorerContentLineNumWidth, applyMiddleDots, } from '../utils/explorerDisplayRows.js';
|
|
6
|
-
import { truncateAnsi } from '../utils/ansiTruncate.js';
|
|
7
|
-
export function ExplorerContentView({ filePath, content, maxHeight, scrollOffset, truncated = false, wrapMode = false, width, showMiddleDots = false, }) {
|
|
8
|
-
// Build base rows with syntax highlighting
|
|
9
|
-
const baseRows = useMemo(() => buildExplorerContentRows(content, filePath, truncated), [content, filePath, truncated]);
|
|
10
|
-
// Calculate line number width
|
|
11
|
-
const lineNumWidth = useMemo(() => getExplorerContentLineNumWidth(baseRows), [baseRows]);
|
|
12
|
-
// Calculate content width for wrapping
|
|
13
|
-
// Layout: paddingX(1) + lineNum + space(1) + content + paddingX(1)
|
|
14
|
-
const contentWidth = width - lineNumWidth - 3;
|
|
15
|
-
// Apply wrapping if enabled
|
|
16
|
-
const displayRows = useMemo(() => wrapExplorerContentRows(baseRows, contentWidth, wrapMode), [baseRows, contentWidth, wrapMode]);
|
|
17
|
-
if (!filePath) {
|
|
18
|
-
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Select a file to view its contents" }) }));
|
|
19
|
-
}
|
|
20
|
-
if (!content) {
|
|
21
|
-
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Loading..." }) }));
|
|
22
|
-
}
|
|
23
|
-
if (displayRows.length === 0) {
|
|
24
|
-
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "(empty file)" }) }));
|
|
25
|
-
}
|
|
26
|
-
return (_jsx(Box, { flexDirection: "column", paddingX: 1, children: _jsx(ScrollableList, { items: displayRows, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (row, index) => `${index}`, renderItem: (row) => {
|
|
27
|
-
if (row.type === 'truncation') {
|
|
28
|
-
return (_jsx(Box, { children: _jsx(Text, { color: "yellow", dimColor: true, children: row.content }) }));
|
|
29
|
-
}
|
|
30
|
-
// Code row
|
|
31
|
-
const isContinuation = row.isContinuation ?? false;
|
|
32
|
-
// Line number display
|
|
33
|
-
let lineNumDisplay;
|
|
34
|
-
if (isContinuation) {
|
|
35
|
-
// Show continuation marker instead of line number
|
|
36
|
-
lineNumDisplay = '>>'.padStart(lineNumWidth, ' ');
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
lineNumDisplay = String(row.lineNum).padStart(lineNumWidth, ' ');
|
|
40
|
-
}
|
|
41
|
-
// Determine what content to display
|
|
42
|
-
const rawContent = row.content;
|
|
43
|
-
const shouldTruncate = !wrapMode && rawContent.length > contentWidth;
|
|
44
|
-
// Use highlighted content if available and not a continuation or middle-dots mode
|
|
45
|
-
const canUseHighlighting = row.highlighted && !isContinuation && !showMiddleDots;
|
|
46
|
-
let displayContent;
|
|
47
|
-
if (canUseHighlighting && row.highlighted) {
|
|
48
|
-
// Use ANSI-aware truncation to preserve syntax highlighting
|
|
49
|
-
displayContent = shouldTruncate
|
|
50
|
-
? truncateAnsi(row.highlighted, contentWidth)
|
|
51
|
-
: row.highlighted;
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
// Plain content path
|
|
55
|
-
let content = rawContent;
|
|
56
|
-
// Apply middle-dots to raw content
|
|
57
|
-
if (showMiddleDots && !isContinuation) {
|
|
58
|
-
content = applyMiddleDots(content, true);
|
|
59
|
-
}
|
|
60
|
-
// Simple truncation for plain content
|
|
61
|
-
if (shouldTruncate) {
|
|
62
|
-
content = content.slice(0, Math.max(0, contentWidth - 1)) + '…';
|
|
63
|
-
}
|
|
64
|
-
displayContent = content;
|
|
65
|
-
}
|
|
66
|
-
return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: !isContinuation, color: isContinuation ? 'cyan' : undefined, children: [lineNumDisplay, ' '] }), _jsx(Text, { dimColor: showMiddleDots && !canUseHighlighting, children: displayContent || ' ' })] }));
|
|
67
|
-
} }) }));
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Get total rows for scroll calculations.
|
|
71
|
-
* Accounts for wrap mode when calculating.
|
|
72
|
-
*/
|
|
73
|
-
export function getExplorerContentTotalRows(content, filePath, truncated, width, wrapMode) {
|
|
74
|
-
if (!content)
|
|
75
|
-
return 0;
|
|
76
|
-
const rows = buildExplorerContentRows(content, filePath, truncated);
|
|
77
|
-
const lineNumWidth = getExplorerContentLineNumWidth(rows);
|
|
78
|
-
const contentWidth = width - lineNumWidth - 3;
|
|
79
|
-
return getExplorerContentRowCount(rows, contentWidth, wrapMode);
|
|
80
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { ScrollableList } from './ScrollableList.js';
|
|
4
|
-
export function ExplorerView({ currentPath: _currentPath, items, selectedIndex, scrollOffset, maxHeight, isActive, width, isLoading = false, error = null, }) {
|
|
5
|
-
if (error) {
|
|
6
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) }));
|
|
7
|
-
}
|
|
8
|
-
if (isLoading) {
|
|
9
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "Loading..." }) }));
|
|
10
|
-
}
|
|
11
|
-
if (items.length === 0) {
|
|
12
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: "(empty directory)" }) }));
|
|
13
|
-
}
|
|
14
|
-
// Calculate max name width for alignment
|
|
15
|
-
const maxNameWidth = Math.min(Math.max(...items.map((item) => item.name.length + (item.isDirectory ? 1 : 0))), width - 10);
|
|
16
|
-
return (_jsx(ScrollableList, { items: items, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (item) => item.path || item.name, renderItem: (item, actualIndex) => {
|
|
17
|
-
const isSelected = actualIndex === selectedIndex && isActive;
|
|
18
|
-
const displayName = item.isDirectory ? `${item.name}/` : item.name;
|
|
19
|
-
const paddedName = displayName.padEnd(maxNameWidth + 1);
|
|
20
|
-
return (_jsx(Box, { children: _jsx(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, inverse: isSelected, children: item.isDirectory ? (_jsx(Text, { color: isSelected ? 'cyan' : 'blue', children: paddedName })) : (_jsx(Text, { color: isSelected ? 'cyan' : undefined, children: paddedName })) }) }));
|
|
21
|
-
} }));
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Build breadcrumb segments from a path.
|
|
25
|
-
* Returns segments like ["src", "components"] for "src/components"
|
|
26
|
-
*/
|
|
27
|
-
export function buildBreadcrumbs(currentPath) {
|
|
28
|
-
if (!currentPath)
|
|
29
|
-
return [];
|
|
30
|
-
return currentPath.split('/').filter(Boolean);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Get total rows in explorer for scroll calculations.
|
|
34
|
-
*/
|
|
35
|
-
export function getExplorerTotalRows(items) {
|
|
36
|
-
return items.length;
|
|
37
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { shortenPath } from '../utils/formatPath.js';
|
|
4
|
-
import { categorizeFiles } from '../utils/fileCategories.js';
|
|
5
|
-
function getStatusChar(status) {
|
|
6
|
-
switch (status) {
|
|
7
|
-
case 'modified':
|
|
8
|
-
return 'M';
|
|
9
|
-
case 'added':
|
|
10
|
-
return 'A';
|
|
11
|
-
case 'deleted':
|
|
12
|
-
return 'D';
|
|
13
|
-
case 'untracked':
|
|
14
|
-
return '?';
|
|
15
|
-
case 'renamed':
|
|
16
|
-
return 'R';
|
|
17
|
-
case 'copied':
|
|
18
|
-
return 'C';
|
|
19
|
-
default:
|
|
20
|
-
return ' ';
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
function getStatusColor(status) {
|
|
24
|
-
switch (status) {
|
|
25
|
-
case 'modified':
|
|
26
|
-
return 'yellow';
|
|
27
|
-
case 'added':
|
|
28
|
-
return 'green';
|
|
29
|
-
case 'deleted':
|
|
30
|
-
return 'red';
|
|
31
|
-
case 'untracked':
|
|
32
|
-
return 'gray';
|
|
33
|
-
case 'renamed':
|
|
34
|
-
return 'blue';
|
|
35
|
-
case 'copied':
|
|
36
|
-
return 'cyan';
|
|
37
|
-
default:
|
|
38
|
-
return 'white';
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
function formatStats(insertions, deletions) {
|
|
42
|
-
if (insertions === undefined && deletions === undefined)
|
|
43
|
-
return null;
|
|
44
|
-
const add = insertions ?? 0;
|
|
45
|
-
const del = deletions ?? 0;
|
|
46
|
-
if (add === 0 && del === 0)
|
|
47
|
-
return null;
|
|
48
|
-
const parts = [];
|
|
49
|
-
if (add > 0)
|
|
50
|
-
parts.push(`+${add}`);
|
|
51
|
-
if (del > 0)
|
|
52
|
-
parts.push(`-${del}`);
|
|
53
|
-
return parts.join(' ');
|
|
54
|
-
}
|
|
55
|
-
function FileRow({ file, isSelected, isFocused, maxPathLength }) {
|
|
56
|
-
const statusChar = getStatusChar(file.status);
|
|
57
|
-
const statusColor = getStatusColor(file.status);
|
|
58
|
-
const actionButton = file.staged ? '[-]' : '[+]';
|
|
59
|
-
const buttonColor = file.staged ? 'red' : 'green';
|
|
60
|
-
const stats = formatStats(file.insertions, file.deletions);
|
|
61
|
-
const isHighlighted = isSelected && isFocused;
|
|
62
|
-
// Calculate available space for path (account for stats if present)
|
|
63
|
-
const statsLength = stats ? stats.length + 1 : 0;
|
|
64
|
-
const availableForPath = maxPathLength - statsLength;
|
|
65
|
-
const displayPath = shortenPath(file.path, availableForPath);
|
|
66
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? 'cyan' : undefined, bold: isHighlighted, children: isHighlighted ? '▸ ' : ' ' }), _jsxs(Text, { color: buttonColor, children: [actionButton, " "] }), _jsxs(Text, { color: statusColor, children: [statusChar, " "] }), _jsx(Text, { color: isHighlighted ? 'cyan' : undefined, inverse: isHighlighted, children: displayPath }), file.originalPath && _jsxs(Text, { dimColor: true, children: [" \u2190 ", shortenPath(file.originalPath, 30)] }), stats && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " " }), file.insertions !== undefined && file.insertions > 0 && (_jsxs(Text, { color: "green", children: ["+", file.insertions] })), file.insertions !== undefined &&
|
|
67
|
-
file.insertions > 0 &&
|
|
68
|
-
file.deletions !== undefined &&
|
|
69
|
-
file.deletions > 0 && _jsx(Text, { dimColor: true, children: " " }), file.deletions !== undefined && file.deletions > 0 && (_jsxs(Text, { color: "red", children: ["-", file.deletions] }))] }))] }));
|
|
70
|
-
}
|
|
71
|
-
export function FileList({ files, selectedIndex, isFocused, scrollOffset = 0, maxHeight, width = 80, }) {
|
|
72
|
-
// Calculate max path length: width minus prefix chars (▸/space + [+]/[-] + status + spaces = ~10)
|
|
73
|
-
const maxPathLength = width - 10;
|
|
74
|
-
// Split files into 3 categories: Modified, Untracked, Staged
|
|
75
|
-
const { modified: modifiedFiles, untracked: untrackedFiles, staged: stagedFiles, } = categorizeFiles(files);
|
|
76
|
-
if (files.length === 0) {
|
|
77
|
-
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { dimColor: true, children: " No changes" }) }));
|
|
78
|
-
}
|
|
79
|
-
// Build a flat list of all rows
|
|
80
|
-
// Order: Modified → Untracked → Staged
|
|
81
|
-
const rows = [];
|
|
82
|
-
let currentFileIndex = 0;
|
|
83
|
-
if (modifiedFiles.length > 0) {
|
|
84
|
-
rows.push({ type: 'header', content: 'Modified:', headerColor: 'yellow' });
|
|
85
|
-
modifiedFiles.forEach((file) => {
|
|
86
|
-
rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
if (untrackedFiles.length > 0) {
|
|
90
|
-
if (modifiedFiles.length > 0) {
|
|
91
|
-
rows.push({ type: 'spacer' });
|
|
92
|
-
}
|
|
93
|
-
rows.push({ type: 'header', content: 'Untracked:', headerColor: 'gray' });
|
|
94
|
-
untrackedFiles.forEach((file) => {
|
|
95
|
-
rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
if (stagedFiles.length > 0) {
|
|
99
|
-
if (modifiedFiles.length > 0 || untrackedFiles.length > 0) {
|
|
100
|
-
rows.push({ type: 'spacer' });
|
|
101
|
-
}
|
|
102
|
-
rows.push({ type: 'header', content: 'Staged:', headerColor: 'green' });
|
|
103
|
-
stagedFiles.forEach((file) => {
|
|
104
|
-
rows.push({ type: 'file', file, fileIndex: currentFileIndex++ });
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
// Apply scroll offset and max height
|
|
108
|
-
const visibleRows = maxHeight
|
|
109
|
-
? rows.slice(scrollOffset, scrollOffset + maxHeight)
|
|
110
|
-
: rows.slice(scrollOffset);
|
|
111
|
-
return (_jsx(Box, { flexDirection: "column", children: visibleRows.map((row, i) => {
|
|
112
|
-
const key = `row-${scrollOffset + i}`;
|
|
113
|
-
if (row.type === 'header') {
|
|
114
|
-
return (_jsx(Text, { bold: true, color: row.headerColor, children: row.content }, key));
|
|
115
|
-
}
|
|
116
|
-
if (row.type === 'spacer') {
|
|
117
|
-
return _jsx(Text, { children: " " }, key);
|
|
118
|
-
}
|
|
119
|
-
if (row.type === 'file' && row.file !== undefined && row.fileIndex !== undefined) {
|
|
120
|
-
return (_jsx(FileRow, { file: row.file, isSelected: row.fileIndex === selectedIndex, isFocused: isFocused, maxPathLength: maxPathLength }, key));
|
|
121
|
-
}
|
|
122
|
-
return null;
|
|
123
|
-
}) }));
|
|
124
|
-
}
|
|
125
|
-
export function getFileAtIndex(files, index) {
|
|
126
|
-
const { ordered } = categorizeFiles(files);
|
|
127
|
-
return ordered[index] ?? null;
|
|
128
|
-
}
|
|
129
|
-
export function getTotalFileCount(files) {
|
|
130
|
-
return files.length;
|
|
131
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
export function Footer({ activeTab, mouseEnabled = true, autoTabEnabled = false, wrapMode = false, showMiddleDots = false, }) {
|
|
4
|
-
// Layout: "? [scroll] [auto] [wrap] [dots]" with spaces between
|
|
5
|
-
return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "?" }), ' ', _jsx(Text, { color: "yellow", children: mouseEnabled ? '[scroll]' : 'm:[select]' }), ' ', _jsx(Text, { color: autoTabEnabled ? 'blue' : undefined, dimColor: !autoTabEnabled, children: "[auto]" }), ' ', _jsx(Text, { color: wrapMode ? 'blue' : undefined, dimColor: !wrapMode, children: "[wrap]" }), activeTab === 'explorer' && (_jsxs(_Fragment, { children: [' ', _jsx(Text, { color: showMiddleDots ? 'blue' : undefined, dimColor: !showMiddleDots, children: "[dots]" })] }))] }), _jsxs(Text, { children: [_jsx(Text, { color: activeTab === 'diff' ? 'cyan' : undefined, bold: activeTab === 'diff', children: "[1]Diff" }), ' ', _jsx(Text, { color: activeTab === 'commit' ? 'cyan' : undefined, bold: activeTab === 'commit', children: "[2]Commit" }), ' ', _jsx(Text, { color: activeTab === 'history' ? 'cyan' : undefined, bold: activeTab === 'history', children: "[3]History" }), ' ', _jsx(Text, { color: activeTab === 'compare' ? 'cyan' : undefined, bold: activeTab === 'compare', children: "[4]Compare" })] })] }));
|
|
6
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { abbreviateHomePath } from '../config.js';
|
|
4
|
-
/**
|
|
5
|
-
* Calculate the header height based on whether content needs to wrap.
|
|
6
|
-
* Returns 1 for single line, 2 if branch wraps to second line.
|
|
7
|
-
*/
|
|
8
|
-
export function getHeaderHeight(repoPath, branch, watcherState, width, error = null, isLoading = false) {
|
|
9
|
-
if (!repoPath)
|
|
10
|
-
return 1;
|
|
11
|
-
const displayPath = abbreviateHomePath(repoPath);
|
|
12
|
-
const isNotGitRepo = error === 'Not a git repository';
|
|
13
|
-
// Calculate branch width
|
|
14
|
-
let branchWidth = 0;
|
|
15
|
-
if (branch) {
|
|
16
|
-
branchWidth = branch.current.length;
|
|
17
|
-
if (branch.tracking)
|
|
18
|
-
branchWidth += 3 + branch.tracking.length;
|
|
19
|
-
if (branch.ahead > 0)
|
|
20
|
-
branchWidth += 3 + String(branch.ahead).length;
|
|
21
|
-
if (branch.behind > 0)
|
|
22
|
-
branchWidth += 3 + String(branch.behind).length;
|
|
23
|
-
}
|
|
24
|
-
// Calculate left side width
|
|
25
|
-
let leftWidth = displayPath.length;
|
|
26
|
-
if (isLoading)
|
|
27
|
-
leftWidth += 2;
|
|
28
|
-
if (isNotGitRepo)
|
|
29
|
-
leftWidth += 24;
|
|
30
|
-
if (error && !isNotGitRepo)
|
|
31
|
-
leftWidth += error.length + 3;
|
|
32
|
-
// Check if follow indicator causes wrap
|
|
33
|
-
if (watcherState?.enabled && watcherState.sourceFile) {
|
|
34
|
-
const followPath = abbreviateHomePath(watcherState.sourceFile);
|
|
35
|
-
const fullFollow = ` (follow: ${followPath})`;
|
|
36
|
-
const availableOneLine = width - leftWidth - branchWidth - 4;
|
|
37
|
-
if (fullFollow.length > availableOneLine) {
|
|
38
|
-
// Would need to wrap
|
|
39
|
-
const availableWithWrap = width - leftWidth - 2;
|
|
40
|
-
if (fullFollow.length <= availableWithWrap) {
|
|
41
|
-
return 2; // Branch wraps to second line
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return 1;
|
|
46
|
-
}
|
|
47
|
-
function BranchDisplay({ branch }) {
|
|
48
|
-
return (_jsxs(Box, { children: [_jsx(Text, { color: "green", bold: true, children: branch.current }), branch.tracking && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u2192 " }), _jsx(Text, { color: "blue", children: branch.tracking })] })), (branch.ahead > 0 || branch.behind > 0) && (_jsxs(Text, { children: [branch.ahead > 0 && _jsxs(Text, { color: "green", children: [" \u2191", branch.ahead] }), branch.behind > 0 && _jsxs(Text, { color: "red", children: [" \u2193", branch.behind] })] }))] }));
|
|
49
|
-
}
|
|
50
|
-
export function Header({ repoPath, branch, isLoading, error, debug, watcherState, width = 80, }) {
|
|
51
|
-
if (!repoPath) {
|
|
52
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Waiting for target path..." }), _jsx(Text, { dimColor: true, children: " (write path to ~/.cache/diffstalker/target)" })] }), debug && watcherState && watcherState.enabled && watcherState.sourceFile && (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["[debug] source: ", abbreviateHomePath(watcherState.sourceFile)] }), watcherState.rawContent && _jsxs(Text, { dimColor: true, children: [" | raw: \"", watcherState.rawContent, "\""] })] }))] }));
|
|
53
|
-
}
|
|
54
|
-
const displayPath = abbreviateHomePath(repoPath);
|
|
55
|
-
const isNotGitRepo = error === 'Not a git repository';
|
|
56
|
-
const formatTime = (date) => {
|
|
57
|
-
if (!date)
|
|
58
|
-
return '';
|
|
59
|
-
return date.toLocaleTimeString();
|
|
60
|
-
};
|
|
61
|
-
// Calculate branch info width for layout
|
|
62
|
-
let branchWidth = 0;
|
|
63
|
-
if (branch) {
|
|
64
|
-
branchWidth = branch.current.length;
|
|
65
|
-
if (branch.tracking) {
|
|
66
|
-
branchWidth += 3 + branch.tracking.length; // " → tracking"
|
|
67
|
-
}
|
|
68
|
-
if (branch.ahead > 0)
|
|
69
|
-
branchWidth += 3 + String(branch.ahead).length;
|
|
70
|
-
if (branch.behind > 0)
|
|
71
|
-
branchWidth += 3 + String(branch.behind).length;
|
|
72
|
-
}
|
|
73
|
-
// Calculate left side content width (without follow)
|
|
74
|
-
let leftWidth = displayPath.length;
|
|
75
|
-
if (isLoading)
|
|
76
|
-
leftWidth += 2;
|
|
77
|
-
if (isNotGitRepo)
|
|
78
|
-
leftWidth += 24;
|
|
79
|
-
if (error && !isNotGitRepo)
|
|
80
|
-
leftWidth += error.length + 3;
|
|
81
|
-
// Determine follow indicator display and layout
|
|
82
|
-
let followText = null;
|
|
83
|
-
let wrapBranch = false;
|
|
84
|
-
if (watcherState?.enabled && watcherState.sourceFile) {
|
|
85
|
-
const followPath = abbreviateHomePath(watcherState.sourceFile);
|
|
86
|
-
const fullFollow = ` (follow: ${followPath})`;
|
|
87
|
-
const availableOneLine = width - leftWidth - branchWidth - 4; // 4 for spacing
|
|
88
|
-
if (fullFollow.length <= availableOneLine) {
|
|
89
|
-
// Everything fits on one line
|
|
90
|
-
followText = fullFollow;
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
// Need to wrap branch to second line
|
|
94
|
-
const availableWithWrap = width - leftWidth - 2;
|
|
95
|
-
if (fullFollow.length <= availableWithWrap) {
|
|
96
|
-
followText = fullFollow;
|
|
97
|
-
wrapBranch = true;
|
|
98
|
-
}
|
|
99
|
-
// If it doesn't fit, don't show follow at all
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, children: [wrapBranch ? (
|
|
103
|
-
// Two-line layout: path + follow on first line, branch on second
|
|
104
|
-
_jsxs(_Fragment, { children: [_jsx(Box, { justifyContent: "space-between", children: _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: displayPath }), isLoading && _jsx(Text, { color: "yellow", children: " \u27F3" }), isNotGitRepo && _jsx(Text, { color: "yellow", children: " (not a git repository)" }), error && !isNotGitRepo && _jsxs(Text, { color: "red", children: [" (", error, ")"] }), followText && _jsx(Text, { dimColor: true, children: followText })] }) }), _jsx(Box, { justifyContent: "flex-end", children: branch && _jsx(BranchDisplay, { branch: branch }) })] })) : (
|
|
105
|
-
// Single-line layout
|
|
106
|
-
_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: displayPath }), isLoading && _jsx(Text, { color: "yellow", children: " \u27F3" }), isNotGitRepo && _jsx(Text, { color: "yellow", children: " (not a git repository)" }), error && !isNotGitRepo && _jsxs(Text, { color: "red", children: [" (", error, ")"] }), followText && _jsx(Text, { dimColor: true, children: followText })] }), branch && _jsx(BranchDisplay, { branch: branch })] })), debug && watcherState && watcherState.enabled && watcherState.sourceFile && (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["[debug] source: ", abbreviateHomePath(watcherState.sourceFile)] }), _jsxs(Text, { dimColor: true, children: [" | raw: \"", watcherState.rawContent, "\""] }), watcherState.lastUpdate && (_jsxs(Text, { dimColor: true, children: [" | updated: ", formatTime(watcherState.lastUpdate)] }))] }))] }));
|
|
107
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
import { ScrollableList } from './ScrollableList.js';
|
|
4
|
-
import { formatDate } from '../utils/formatDate.js';
|
|
5
|
-
import { formatCommitDisplay } from '../utils/commitFormat.js';
|
|
6
|
-
// Re-export from utils for backwards compatibility
|
|
7
|
-
export { getCommitIndexFromRow, getHistoryTotalRows, getHistoryRowOffset, } from '../utils/rowCalculations.js';
|
|
8
|
-
export function HistoryView({ commits, selectedIndex, scrollOffset, maxHeight, isActive, width, onSelectCommit: _onSelectCommit, }) {
|
|
9
|
-
if (commits.length === 0) {
|
|
10
|
-
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No commits yet" }) }));
|
|
11
|
-
}
|
|
12
|
-
return (_jsx(ScrollableList, { items: commits, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (commit) => commit.hash, renderItem: (commit, actualIndex) => {
|
|
13
|
-
const isSelected = actualIndex === selectedIndex && isActive;
|
|
14
|
-
const dateStr = formatDate(commit.date);
|
|
15
|
-
// Fixed parts: hash(7) + spaces(4) + date + parens(2)
|
|
16
|
-
const baseWidth = 7 + 4 + dateStr.length + 2;
|
|
17
|
-
const remainingWidth = width - baseWidth;
|
|
18
|
-
const { displayMessage, displayRefs } = formatCommitDisplay(commit.message, commit.refs, remainingWidth);
|
|
19
|
-
return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: commit.shortHash }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, inverse: isSelected, children: displayMessage }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["(", dateStr, ")"] }), displayRefs && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: displayRefs })] }))] }));
|
|
20
|
-
} }));
|
|
21
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text, useInput } from 'ink';
|
|
3
|
-
import { Modal, centerModal } from './Modal.js';
|
|
4
|
-
const hotkeyGroups = [
|
|
5
|
-
{
|
|
6
|
-
title: 'Navigation',
|
|
7
|
-
entries: [
|
|
8
|
-
{ key: '↑/k', description: 'Move up' },
|
|
9
|
-
{ key: '↓/j', description: 'Move down' },
|
|
10
|
-
{ key: 'Tab', description: 'Toggle pane focus' },
|
|
11
|
-
],
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
title: 'Staging',
|
|
15
|
-
entries: [
|
|
16
|
-
{ key: '^S', description: 'Stage file' },
|
|
17
|
-
{ key: '^U', description: 'Unstage file' },
|
|
18
|
-
{ key: '^A', description: 'Stage all' },
|
|
19
|
-
{ key: '^Z', description: 'Unstage all' },
|
|
20
|
-
{ key: 'Space/Enter', description: 'Toggle stage' },
|
|
21
|
-
],
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
title: 'Actions',
|
|
25
|
-
entries: [
|
|
26
|
-
{ key: 'c', description: 'Open commit panel' },
|
|
27
|
-
{ key: 'r', description: 'Refresh' },
|
|
28
|
-
{ key: 'q', description: 'Quit' },
|
|
29
|
-
],
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
title: 'Pane Resize',
|
|
33
|
-
entries: [
|
|
34
|
-
{ key: '[', description: 'Shrink top pane' },
|
|
35
|
-
{ key: ']', description: 'Grow top pane' },
|
|
36
|
-
],
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
title: 'Tabs',
|
|
40
|
-
entries: [
|
|
41
|
-
{ key: '1', description: 'Diff view' },
|
|
42
|
-
{ key: '2', description: 'Commit panel' },
|
|
43
|
-
{ key: '3', description: 'History view' },
|
|
44
|
-
{ key: '4', description: 'Compare view' },
|
|
45
|
-
{ key: '5', description: 'Explorer view' },
|
|
46
|
-
{ key: 'a', description: 'Toggle auto-tab mode' },
|
|
47
|
-
],
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
title: 'Explorer',
|
|
51
|
-
entries: [
|
|
52
|
-
{ key: '.', description: 'Toggle middle-dots' },
|
|
53
|
-
{ key: '^H', description: 'Toggle hidden files' },
|
|
54
|
-
{ key: '^G', description: 'Toggle gitignored' },
|
|
55
|
-
{ key: 'Enter', description: 'Enter directory' },
|
|
56
|
-
{ key: 'Backspace/h', description: 'Go up' },
|
|
57
|
-
],
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
title: 'Other',
|
|
61
|
-
entries: [
|
|
62
|
-
{ key: 'm', description: 'Toggle scroll/select' },
|
|
63
|
-
{ key: 'f', description: 'Toggle follow mode' },
|
|
64
|
-
{ key: 'w', description: 'Toggle wrap mode' },
|
|
65
|
-
{ key: 't', description: 'Theme picker' },
|
|
66
|
-
{ key: 'b', description: 'Base branch picker' },
|
|
67
|
-
{ key: 'u', description: 'Toggle uncommitted' },
|
|
68
|
-
{ key: '?', description: 'This help' },
|
|
69
|
-
],
|
|
70
|
-
},
|
|
71
|
-
];
|
|
72
|
-
export function HotkeysModal({ onClose, width, height }) {
|
|
73
|
-
useInput((input, key) => {
|
|
74
|
-
if (key.escape || key.return || input === '?') {
|
|
75
|
-
onClose();
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
// Determine if we should use 2 columns (need at least 90 chars width)
|
|
79
|
-
const useTwoColumns = width >= 90;
|
|
80
|
-
const columnWidth = useTwoColumns ? 38 : 30;
|
|
81
|
-
const boxWidth = useTwoColumns ? Math.min(82, width - 4) : Math.min(40, width - 4);
|
|
82
|
-
// Calculate height based on layout
|
|
83
|
-
let boxHeight;
|
|
84
|
-
if (useTwoColumns) {
|
|
85
|
-
// Split groups into two columns
|
|
86
|
-
const midpoint = Math.ceil(hotkeyGroups.length / 2);
|
|
87
|
-
const leftGroups = hotkeyGroups.slice(0, midpoint);
|
|
88
|
-
const rightGroups = hotkeyGroups.slice(midpoint);
|
|
89
|
-
const leftLines = leftGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
|
|
90
|
-
const rightLines = rightGroups.reduce((sum, g) => sum + g.entries.length + 2, 0);
|
|
91
|
-
boxHeight = Math.min(Math.max(leftLines, rightLines) + 5, height - 4);
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
const totalLines = hotkeyGroups.reduce((sum, g) => sum + g.entries.length + 2, 0) + 4;
|
|
95
|
-
boxHeight = Math.min(totalLines, height - 4);
|
|
96
|
-
}
|
|
97
|
-
// Center the modal
|
|
98
|
-
const { x, y } = centerModal(boxWidth, boxHeight, width, height);
|
|
99
|
-
// Render a single group
|
|
100
|
-
const renderGroup = (group, colWidth) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, dimColor: true, children: group.title }), group.entries.map((entry) => (_jsxs(Box, { children: [_jsx(Box, { width: 13, children: _jsx(Text, { color: "cyan", children: entry.key }) }), _jsx(Box, { width: colWidth - 13, children: _jsx(Text, { children: entry.description }) })] }, entry.key)))] }, group.title));
|
|
101
|
-
if (useTwoColumns) {
|
|
102
|
-
const midpoint = Math.ceil(hotkeyGroups.length / 2);
|
|
103
|
-
const leftGroups = hotkeyGroups.slice(0, midpoint);
|
|
104
|
-
const rightGroups = hotkeyGroups.slice(midpoint);
|
|
105
|
-
return (_jsx(Modal, { x: x, y: y, width: boxWidth, height: boxHeight, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", width: boxWidth, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: [' ', "Keyboard Shortcuts", ' '] }) }), _jsxs(Box, { children: [_jsx(Box, { flexDirection: "column", width: columnWidth, marginRight: 2, children: leftGroups.map((g) => renderGroup(g, columnWidth)) }), _jsx(Box, { flexDirection: "column", width: columnWidth, children: rightGroups.map((g) => renderGroup(g, columnWidth)) })] }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "Press Esc, Enter, or ? to close" }) })] }) }));
|
|
106
|
-
}
|
|
107
|
-
return (_jsx(Modal, { x: x, y: y, width: boxWidth, height: boxHeight, children: _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", width: boxWidth, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsxs(Text, { bold: true, color: "cyan", children: [' ', "Keyboard Shortcuts", ' '] }) }), hotkeyGroups.map((group) => renderGroup(group, columnWidth)), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "Press Esc, Enter, or ? to close" }) })] }) }));
|
|
108
|
-
}
|
package/dist/components/Modal.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from 'ink';
|
|
3
|
-
/**
|
|
4
|
-
* A modal overlay that blankets only its own area before rendering children.
|
|
5
|
-
* Use this to create popups that cover the content behind them.
|
|
6
|
-
*/
|
|
7
|
-
export function Modal({ x, y, width, height, children }) {
|
|
8
|
-
const blankLine = ' '.repeat(width);
|
|
9
|
-
return (_jsxs(Box, { position: "absolute", marginLeft: x, marginTop: y, flexDirection: "column", children: [Array.from({ length: height }).map((_, i) => (_jsx(Text, { children: blankLine }, `blank-${i}`))), _jsx(Box, { position: "absolute", flexDirection: "column", children: children })] }));
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Helper to calculate centered modal position.
|
|
13
|
-
*/
|
|
14
|
-
export function centerModal(modalWidth, modalHeight, screenWidth, screenHeight) {
|
|
15
|
-
return {
|
|
16
|
-
x: Math.floor((screenWidth - modalWidth) / 2),
|
|
17
|
-
y: Math.floor((screenHeight - modalHeight) / 2),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo } from 'react';
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
4
|
-
/**
|
|
5
|
-
* A generic scrollable list component that properly handles:
|
|
6
|
-
* - Scroll indicators (↑/↓) taking up space
|
|
7
|
-
* - Consistent height calculations
|
|
8
|
-
* - Proper React keys for re-rendering
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* ```tsx
|
|
12
|
-
* <ScrollableList
|
|
13
|
-
* items={myItems}
|
|
14
|
-
* renderItem={(item, i) => <MyRow item={item} />}
|
|
15
|
-
* maxHeight={20}
|
|
16
|
-
* scrollOffset={offset}
|
|
17
|
-
* getKey={(item, i) => item.id}
|
|
18
|
-
* />
|
|
19
|
-
* ```
|
|
20
|
-
*/
|
|
21
|
-
export function ScrollableList({ items, renderItem, maxHeight, scrollOffset, getKey, header, showIndicators = true, getItemHeight, }) {
|
|
22
|
-
// If getItemHeight is not provided, use simple item-based scrolling
|
|
23
|
-
const hasVariableHeight = !!getItemHeight;
|
|
24
|
-
// Calculate cumulative row positions for variable height items
|
|
25
|
-
const { itemRowStarts, totalRows } = useMemo(() => {
|
|
26
|
-
if (!hasVariableHeight) {
|
|
27
|
-
return { itemRowStarts: [], totalRows: items.length };
|
|
28
|
-
}
|
|
29
|
-
const starts = [];
|
|
30
|
-
let cumulative = 0;
|
|
31
|
-
for (let i = 0; i < items.length; i++) {
|
|
32
|
-
starts.push(cumulative);
|
|
33
|
-
cumulative += getItemHeight(items[i], i);
|
|
34
|
-
}
|
|
35
|
-
return { itemRowStarts: starts, totalRows: cumulative };
|
|
36
|
-
}, [items, getItemHeight, hasVariableHeight]);
|
|
37
|
-
// Calculate available space for actual content
|
|
38
|
-
let availableHeight = maxHeight;
|
|
39
|
-
// Reserve space for header if present
|
|
40
|
-
if (header) {
|
|
41
|
-
availableHeight--;
|
|
42
|
-
}
|
|
43
|
-
const hasPrevious = scrollOffset > 0;
|
|
44
|
-
const contentTotal = hasVariableHeight ? totalRows : items.length;
|
|
45
|
-
const needsScrolling = contentTotal > maxHeight;
|
|
46
|
-
// Simple rule: if content needs scrolling, ALWAYS reserve 2 rows for indicators
|
|
47
|
-
// No clever predictions - just consistent, predictable behavior
|
|
48
|
-
if (showIndicators && needsScrolling) {
|
|
49
|
-
availableHeight -= 2;
|
|
50
|
-
}
|
|
51
|
-
// Ensure we have at least 1 line for content
|
|
52
|
-
availableHeight = Math.max(1, availableHeight);
|
|
53
|
-
// Find visible items based on scroll offset (in rows)
|
|
54
|
-
const visibleItems = [];
|
|
55
|
-
let usedRows = 0;
|
|
56
|
-
let rowsAbove = 0;
|
|
57
|
-
let rowsBelow = 0;
|
|
58
|
-
if (hasVariableHeight) {
|
|
59
|
-
// Find first visible item (the one that contains scrollOffset row)
|
|
60
|
-
let startIdx = 0;
|
|
61
|
-
for (let i = 0; i < items.length; i++) {
|
|
62
|
-
const itemHeight = getItemHeight(items[i], i);
|
|
63
|
-
if (itemRowStarts[i] + itemHeight > scrollOffset) {
|
|
64
|
-
startIdx = i;
|
|
65
|
-
break;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// Collect items that fit in available height
|
|
69
|
-
for (let i = startIdx; i < items.length && usedRows < availableHeight; i++) {
|
|
70
|
-
const itemHeight = getItemHeight(items[i], i);
|
|
71
|
-
visibleItems.push({ item: items[i], index: i });
|
|
72
|
-
usedRows += itemHeight;
|
|
73
|
-
}
|
|
74
|
-
rowsAbove = scrollOffset;
|
|
75
|
-
// Simple calculation: total rows minus what we've scrolled past minus what we're showing
|
|
76
|
-
rowsBelow = Math.max(0, totalRows - scrollOffset - usedRows);
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
// Simple item-based scrolling (1 item = 1 row)
|
|
80
|
-
const endIdx = Math.min(scrollOffset + availableHeight, items.length);
|
|
81
|
-
for (let i = scrollOffset; i < endIdx; i++) {
|
|
82
|
-
visibleItems.push({ item: items[i], index: i });
|
|
83
|
-
usedRows++;
|
|
84
|
-
}
|
|
85
|
-
rowsAbove = scrollOffset;
|
|
86
|
-
rowsBelow = Math.max(0, items.length - scrollOffset - usedRows);
|
|
87
|
-
}
|
|
88
|
-
return (_jsxs(Box, { flexDirection: "column", overflowX: "hidden", height: maxHeight, overflow: "hidden", children: [header, showIndicators &&
|
|
89
|
-
needsScrolling &&
|
|
90
|
-
(hasPrevious ? _jsxs(Text, { dimColor: true, children: ["\u2191 ", rowsAbove, " more above"] }) : _jsx(Text, { children: " " })), visibleItems.map(({ item, index }) => (_jsx(Box, { children: renderItem(item, index) }, `${scrollOffset}-${index}-${getKey(item, index)}`))), showIndicators &&
|
|
91
|
-
needsScrolling &&
|
|
92
|
-
(rowsBelow > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2193 ", rowsBelow, " more below"] }) : _jsx(Text, { children: " " }))] }));
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Calculate the maximum scroll offset for a list.
|
|
96
|
-
*/
|
|
97
|
-
export function getMaxScrollOffset(totalItems, maxHeight, hasHeader = false, showIndicators = true) {
|
|
98
|
-
let availableHeight = maxHeight;
|
|
99
|
-
if (hasHeader)
|
|
100
|
-
availableHeight--;
|
|
101
|
-
// When scrolled, we always show "↑ above" indicator
|
|
102
|
-
// and usually "↓ below" indicator, so subtract 2
|
|
103
|
-
if (showIndicators && totalItems > availableHeight) {
|
|
104
|
-
availableHeight -= 2;
|
|
105
|
-
}
|
|
106
|
-
availableHeight = Math.max(1, availableHeight);
|
|
107
|
-
return Math.max(0, totalItems - availableHeight);
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Calculate visible item count for a given configuration.
|
|
111
|
-
* Useful for scroll calculations in parent components.
|
|
112
|
-
*/
|
|
113
|
-
export function getVisibleItemCount(totalItems, maxHeight, scrollOffset, hasHeader = false, showIndicators = true) {
|
|
114
|
-
let availableHeight = maxHeight;
|
|
115
|
-
if (hasHeader)
|
|
116
|
-
availableHeight--;
|
|
117
|
-
if (showIndicators) {
|
|
118
|
-
if (scrollOffset > 0)
|
|
119
|
-
availableHeight--;
|
|
120
|
-
if (totalItems > scrollOffset + availableHeight)
|
|
121
|
-
availableHeight--;
|
|
122
|
-
}
|
|
123
|
-
availableHeight = Math.max(1, availableHeight);
|
|
124
|
-
return Math.min(availableHeight, totalItems - scrollOffset);
|
|
125
|
-
}
|