diffstalker 0.1.6 → 0.1.7

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.
Files changed (53) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/bun.lock +618 -0
  3. package/dist/App.js +541 -1
  4. package/dist/components/BaseBranchPicker.js +60 -1
  5. package/dist/components/BottomPane.js +101 -1
  6. package/dist/components/CommitPanel.js +58 -1
  7. package/dist/components/CompareListView.js +110 -1
  8. package/dist/components/ExplorerContentView.js +80 -3
  9. package/dist/components/ExplorerView.js +37 -1
  10. package/dist/components/FileList.js +131 -1
  11. package/dist/components/Footer.js +6 -1
  12. package/dist/components/Header.js +107 -1
  13. package/dist/components/HistoryView.js +21 -1
  14. package/dist/components/HotkeysModal.js +108 -1
  15. package/dist/components/Modal.js +19 -1
  16. package/dist/components/ScrollableList.js +125 -1
  17. package/dist/components/ThemePicker.js +42 -1
  18. package/dist/components/TopPane.js +14 -1
  19. package/dist/components/UnifiedDiffView.js +115 -1
  20. package/dist/config.js +83 -2
  21. package/dist/core/GitOperationQueue.js +109 -1
  22. package/dist/core/GitStateManager.js +466 -1
  23. package/dist/git/diff.js +471 -10
  24. package/dist/git/status.js +269 -5
  25. package/dist/hooks/useCommitFlow.js +66 -1
  26. package/dist/hooks/useCompareState.js +123 -1
  27. package/dist/hooks/useExplorerState.js +248 -9
  28. package/dist/hooks/useGit.js +156 -1
  29. package/dist/hooks/useHistoryState.js +62 -1
  30. package/dist/hooks/useKeymap.js +167 -1
  31. package/dist/hooks/useLayout.js +154 -1
  32. package/dist/hooks/useMouse.js +87 -1
  33. package/dist/hooks/useTerminalSize.js +20 -1
  34. package/dist/hooks/useWatcher.js +137 -11
  35. package/dist/index.js +43 -3
  36. package/dist/services/commitService.js +22 -1
  37. package/dist/themes.js +127 -1
  38. package/dist/utils/ansiTruncate.js +108 -0
  39. package/dist/utils/baseBranchCache.js +44 -2
  40. package/dist/utils/commitFormat.js +38 -1
  41. package/dist/utils/diffFilters.js +21 -1
  42. package/dist/utils/diffRowCalculations.js +113 -1
  43. package/dist/utils/displayRows.js +172 -2
  44. package/dist/utils/explorerDisplayRows.js +169 -0
  45. package/dist/utils/fileCategories.js +26 -1
  46. package/dist/utils/formatDate.js +39 -1
  47. package/dist/utils/formatPath.js +58 -1
  48. package/dist/utils/languageDetection.js +180 -0
  49. package/dist/utils/layoutCalculations.js +98 -1
  50. package/dist/utils/lineBreaking.js +88 -5
  51. package/dist/utils/mouseCoordinates.js +165 -1
  52. package/dist/utils/rowCalculations.js +209 -4
  53. package/package.json +7 -10
@@ -1 +1,107 @@
1
- import{jsx as n,Fragment as W,jsxs as i}from"react/jsx-runtime";import{Box as d,Text as e}from"ink";import{abbreviateHomePath as p}from"../config.js";export function getHeaderHeight(t,l,s,c,g=null,o=!1){if(!t)return 1;const y=p(t),a=g==="Not a git repository";let r=0;l&&(r=l.current.length,l.tracking&&(r+=3+l.tracking.length),l.ahead>0&&(r+=3+String(l.ahead).length),l.behind>0&&(r+=3+String(l.behind).length));let h=y.length;if(o&&(h+=2),a&&(h+=24),g&&!a&&(h+=g.length+3),s?.enabled&&s.sourceFile){const u=` (follow: ${p(s.sourceFile)})`,f=c-h-r-4;if(u.length>f){const C=c-h-2;if(u.length<=C)return 2}}return 1}function j({branch:t}){return i(d,{children:[n(e,{color:"green",bold:!0,children:t.current}),t.tracking&&i(W,{children:[n(e,{dimColor:!0,children:" \u2192 "}),n(e,{color:"blue",children:t.tracking})]}),(t.ahead>0||t.behind>0)&&i(e,{children:[t.ahead>0&&i(e,{color:"green",children:[" \u2191",t.ahead]}),t.behind>0&&i(e,{color:"red",children:[" \u2193",t.behind]})]})]})}export function Header({repoPath:t,branch:l,isLoading:s,error:c,debug:g,watcherState:o,width:y=80}){if(!t)return i(d,{flexDirection:"column",children:[i(d,{children:[n(e,{dimColor:!0,children:"Waiting for target path..."}),n(e,{dimColor:!0,children:" (write path to ~/.cache/diffstalker/target)"})]}),g&&o&&o.enabled&&o.sourceFile&&i(d,{children:[i(e,{dimColor:!0,children:["[debug] source: ",p(o.sourceFile)]}),o.rawContent&&i(e,{dimColor:!0,children:[' | raw: "',o.rawContent,'"']})]})]});const a=p(t),r=c==="Not a git repository",h=x=>x?x.toLocaleTimeString():"";let m=0;l&&(m=l.current.length,l.tracking&&(m+=3+l.tracking.length),l.ahead>0&&(m+=3+String(l.ahead).length),l.behind>0&&(m+=3+String(l.behind).length));let u=a.length;s&&(u+=2),r&&(u+=24),c&&!r&&(u+=c.length+3);let f=null,C=!1;if(o?.enabled&&o.sourceFile){const F=` (follow: ${p(o.sourceFile)})`,k=y-u-m-4;if(F.length<=k)f=F;else{const v=y-u-2;F.length<=v&&(f=F,C=!0)}}return i(d,{flexDirection:"column",width:y,children:[C?i(W,{children:[n(d,{justifyContent:"space-between",children:i(d,{children:[n(e,{bold:!0,color:"cyan",children:a}),s&&n(e,{color:"yellow",children:" \u27F3"}),r&&n(e,{color:"yellow",children:" (not a git repository)"}),c&&!r&&i(e,{color:"red",children:[" (",c,")"]}),f&&n(e,{dimColor:!0,children:f})]})}),n(d,{justifyContent:"flex-end",children:l&&n(j,{branch:l})})]}):i(d,{justifyContent:"space-between",children:[i(d,{children:[n(e,{bold:!0,color:"cyan",children:a}),s&&n(e,{color:"yellow",children:" \u27F3"}),r&&n(e,{color:"yellow",children:" (not a git repository)"}),c&&!r&&i(e,{color:"red",children:[" (",c,")"]}),f&&n(e,{dimColor:!0,children:f})]}),l&&n(j,{branch:l})]}),g&&o&&o.enabled&&o.sourceFile&&i(d,{children:[i(e,{dimColor:!0,children:["[debug] source: ",p(o.sourceFile)]}),i(e,{dimColor:!0,children:[' | raw: "',o.rawContent,'"']}),o.lastUpdate&&i(e,{dimColor:!0,children:[" | updated: ",h(o.lastUpdate)]})]})]})}
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 +1,21 @@
1
- import{jsx as e,jsxs as i,Fragment as d}from"react/jsx-runtime";import{Box as u,Text as r}from"ink";import{ScrollableList as C}from"./ScrollableList.js";import{formatDate as w}from"../utils/formatDate.js";import{formatCommitDisplay as H}from"../utils/commitFormat.js";export{getCommitIndexFromRow,getHistoryTotalRows,getHistoryRowOffset}from"../utils/rowCalculations.js";export function HistoryView({commits:s,selectedIndex:m,scrollOffset:a,maxHeight:c,isActive:h,width:f,onSelectCommit:S}){return s.length===0?e(u,{children:e(r,{dimColor:!0,children:"No commits yet"})}):e(C,{items:s,maxHeight:c,scrollOffset:a,getKey:t=>t.hash,renderItem:(t,g)=>{const o=g===m&&h,n=w(t.date),p=11+n.length+2,x=f-p,{displayMessage:y,displayRefs:l}=H(t.message,t.refs,x);return i(d,{children:[e(r,{color:"yellow",children:t.shortHash}),e(r,{children:" "}),e(r,{color:o?"cyan":void 0,bold:o,inverse:o,children:y}),e(r,{children:" "}),i(r,{dimColor:!0,children:["(",n,")"]}),l&&i(d,{children:[e(r,{children:" "}),e(r,{color:"green",children:l})]})]})}})}
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 +1,108 @@
1
- import{jsx as t,jsxs as c}from"react/jsx-runtime";import{Box as i,Text as s,useInput as C}from"ink";import{Modal as b,centerModal as M}from"./Modal.js";const r=[{title:"Navigation",entries:[{key:"\u2191/k",description:"Move up"},{key:"\u2193/j",description:"Move down"},{key:"Tab",description:"Toggle pane focus"}]},{title:"Staging",entries:[{key:"^S",description:"Stage file"},{key:"^U",description:"Unstage file"},{key:"^A",description:"Stage all"},{key:"^Z",description:"Unstage all"},{key:"Space/Enter",description:"Toggle stage"}]},{title:"Actions",entries:[{key:"c",description:"Open commit panel"},{key:"r",description:"Refresh"},{key:"q",description:"Quit"}]},{title:"Pane Resize",entries:[{key:"[",description:"Shrink top pane"},{key:"]",description:"Grow top pane"}]},{title:"Tabs",entries:[{key:"1",description:"Diff view"},{key:"2",description:"Commit panel"},{key:"3",description:"History view"},{key:"4",description:"Compare view"},{key:"a",description:"Toggle auto-tab mode"}]},{title:"Other",entries:[{key:"m",description:"Toggle scroll/select mode"},{key:"f",description:"Toggle follow mode"},{key:"w",description:"Toggle wrap mode"},{key:"t",description:"Theme picker"},{key:"b",description:"Base branch picker"},{key:"u",description:"Toggle uncommitted"},{key:"?",description:"This help"}]}];export function HotkeysModal({onClose:T,width:h,height:y}){C((e,o)=>{(o.escape||o.return||e==="?")&&T()});const m=h>=90,l=m?38:30,d=Math.min(m?82:40,h-4);let p;if(m){const e=Math.ceil(r.length/2),o=r.slice(0,e),n=r.slice(e),a=o.reduce((g,k)=>g+k.entries.length+2,0),w=n.reduce((g,k)=>g+k.entries.length+2,0);p=Math.min(Math.max(a,w)+5,y-4)}else{const e=r.reduce((o,n)=>o+n.entries.length+2,0)+4;p=Math.min(e,y-4)}const{x:f,y:x}=M(d,p,h,y),u=(e,o)=>c(i,{flexDirection:"column",marginBottom:1,children:[t(s,{bold:!0,dimColor:!0,children:e.title}),e.entries.map(n=>c(i,{children:[t(i,{width:13,children:t(s,{color:"cyan",children:n.key})}),t(i,{width:o-13,children:t(s,{children:n.description})})]},n.key))]},e.title);if(m){const e=Math.ceil(r.length/2),o=r.slice(0,e),n=r.slice(e);return t(b,{x:f,y:x,width:d,height:p,children:c(i,{borderStyle:"round",borderColor:"cyan",flexDirection:"column",width:d,children:[t(i,{justifyContent:"center",marginBottom:1,children:c(s,{bold:!0,color:"cyan",children:[" ","Keyboard Shortcuts"," "]})}),c(i,{children:[t(i,{flexDirection:"column",width:l,marginRight:2,children:o.map(a=>u(a,l))}),t(i,{flexDirection:"column",width:l,children:n.map(a=>u(a,l))})]}),t(i,{marginTop:1,justifyContent:"center",children:t(s,{dimColor:!0,children:"Press Esc, Enter, or ? to close"})})]})})}return t(b,{x:f,y:x,width:d,height:p,children:c(i,{borderStyle:"round",borderColor:"cyan",flexDirection:"column",width:d,children:[t(i,{justifyContent:"center",marginBottom:1,children:c(s,{bold:!0,color:"cyan",children:[" ","Keyboard Shortcuts"," "]})}),r.map(e=>u(e,l)),t(i,{marginTop:1,justifyContent:"center",children:t(s,{dimColor:!0,children:"Press Esc, Enter, or ? to close"})})]})})}
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
+ }
@@ -1 +1,19 @@
1
- import{jsx as e,jsxs as c}from"react/jsx-runtime";import{Box as i,Text as x}from"ink";export function Modal({x:o,y:r,width:n,height:t,children:l}){const a=" ".repeat(n);return c(i,{position:"absolute",marginLeft:o,marginTop:r,flexDirection:"column",children:[Array.from({length:t}).map((f,s)=>e(x,{children:a},`blank-${s}`)),e(i,{position:"absolute",flexDirection:"column",children:l})]})}export function centerModal(o,r,n,t){return{x:Math.floor((n-o)/2),y:Math.floor((t-r)/2)}}
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 +1,125 @@
1
- import{jsxs as s,jsx as f}from"react/jsx-runtime";import{useMemo as C}from"react";import{Box as R,Text as w}from"ink";export function ScrollableList({items:e,renderItem:u,maxHeight:i,scrollOffset:o,getKey:n,header:r,showIndicators:m=!0,getItemHeight:x}){const d=!!x,{itemRowStarts:S,totalRows:g}=C(()=>{if(!d)return{itemRowStarts:[],totalRows:e.length};const a=[];let t=0;for(let l=0;l<e.length;l++)a.push(t),t+=x(e[l],l);return{itemRowStarts:a,totalRows:t}},[e,x,d]);let h=i;r&&h--;const j=o>0,p=(d?g:e.length)>i;m&&p&&(h-=2),h=Math.max(1,h);const v=[];let c=0,M=0,b=0;if(d){let a=0;for(let t=0;t<e.length;t++){const l=x(e[t],t);if(S[t]+l>o){a=t;break}}for(let t=a;t<e.length&&c<h;t++){const l=x(e[t],t);v.push({item:e[t],index:t}),c+=l}M=o,b=Math.max(0,g-o-c)}else{const a=Math.min(o+h,e.length);for(let t=o;t<a;t++)v.push({item:e[t],index:t}),c++;M=o,b=Math.max(0,e.length-o-c)}return s(R,{flexDirection:"column",overflowX:"hidden",height:i,overflow:"hidden",children:[r,m&&p&&(j?s(w,{dimColor:!0,children:["\u2191 ",M," more above"]}):f(w,{children:" "})),v.map(({item:a,index:t})=>f(R,{children:u(a,t)},`${o}-${t}-${n(a,t)}`)),m&&p&&(b>0?s(w,{dimColor:!0,children:["\u2193 ",b," more below"]}):f(w,{children:" "}))]})}export function getMaxScrollOffset(e,u,i=!1,o=!0){let n=u;return i&&n--,o&&e>n&&(n-=2),n=Math.max(1,n),Math.max(0,e-n)}export function getVisibleItemCount(e,u,i,o=!1,n=!0){let r=u;return o&&r--,n&&(i>0&&r--,e>i+r&&r--),r=Math.max(1,r),Math.min(r,e-i)}
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
+ }
@@ -1 +1,42 @@
1
- import{jsx as o,jsxs as c}from"react/jsx-runtime";import{useState as y}from"react";import{Box as l,Text as r,useInput as w}from"ink";import{themes as j,themeOrder as t,getTheme as S}from"../themes.js";import{Modal as T,centerModal as v}from"./Modal.js";function M({theme:h}){const{colors:e}=h;return c(l,{flexDirection:"column",marginLeft:2,children:[c(l,{children:[o(r,{backgroundColor:e.delBg,color:e.delLineNum,children:" 5 "}),o(r,{backgroundColor:e.delBg,color:e.delSymbol,bold:!0,children:"- "}),o(r,{backgroundColor:e.delBg,color:e.text,children:"const "}),o(r,{backgroundColor:e.delHighlight,color:e.text,children:"old"}),o(r,{backgroundColor:e.delBg,color:e.text,children:" = value;"})]}),c(l,{children:[o(r,{backgroundColor:e.addBg,color:e.addLineNum,children:" 5 "}),o(r,{backgroundColor:e.addBg,color:e.addSymbol,bold:!0,children:"+ "}),o(r,{backgroundColor:e.addBg,color:e.text,children:"const "}),o(r,{backgroundColor:e.addHighlight,color:e.text,children:"new"}),o(r,{backgroundColor:e.addBg,color:e.text,children:" = value;"})]})]})}export function ThemePicker({currentTheme:h,onSelect:e,onCancel:f,width:m,height:g}){const[a,x]=y(()=>{const n=t.indexOf(h);return n>=0?n:0}),C=S(t[a]);w((n,d)=>{d.escape?f():d.return?e(t[a]):d.upArrow||n==="k"?x(i=>Math.max(0,i-1)):(d.downArrow||n==="j")&&x(i=>Math.min(t.length-1,i+1))});const s=Math.min(50,m-4),b=Math.min(t.length+10,g-4),{x:p,y:B}=v(s,b,m,g);return o(T,{x:p,y:B,width:s,height:b,children:c(l,{borderStyle:"round",borderColor:"cyan",flexDirection:"column",width:s,children:[o(l,{justifyContent:"center",marginBottom:1,children:c(r,{bold:!0,color:"cyan",children:[" ","Select Theme"," "]})}),t.map((n,d)=>{const i=j[n],u=d===a,k=n===h;return c(l,{children:[o(r,{color:u?"cyan":void 0,children:u?"\u25B8 ":" "}),o(r,{bold:u,color:u?"cyan":void 0,children:i.displayName}),k&&o(r,{dimColor:!0,children:" (current)"})]},n)}),c(l,{marginTop:1,flexDirection:"column",children:[o(r,{dimColor:!0,children:"Preview:"}),o(M,{theme:C})]}),o(l,{marginTop:1,justifyContent:"center",children:o(r,{dimColor:!0,children:"\u2191\u2193 navigate \u2022 Enter select \u2022 Esc cancel"})})]})})}
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { themes, themeOrder, getTheme } from '../themes.js';
5
+ import { Modal, centerModal } from './Modal.js';
6
+ // Preview sample for theme visualization
7
+ function ThemePreview({ theme }) {
8
+ const { colors } = theme;
9
+ return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.delBg, color: colors.delLineNum, children: ' 5 ' }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.delSymbol, bold: true, children: '- ' }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.text, children: 'const ' }), _jsx(Text, { backgroundColor: colors.delHighlight, color: colors.text, children: 'old' }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.text, children: ' = value;' })] }), _jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.addBg, color: colors.addLineNum, children: ' 5 ' }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.addSymbol, bold: true, children: '+ ' }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.text, children: 'const ' }), _jsx(Text, { backgroundColor: colors.addHighlight, color: colors.text, children: 'new' }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.text, children: ' = value;' })] })] }));
10
+ }
11
+ export function ThemePicker({ currentTheme, onSelect, onCancel, width, height, }) {
12
+ const [selectedIndex, setSelectedIndex] = useState(() => {
13
+ const idx = themeOrder.indexOf(currentTheme);
14
+ return idx >= 0 ? idx : 0;
15
+ });
16
+ const previewTheme = getTheme(themeOrder[selectedIndex]);
17
+ useInput((input, key) => {
18
+ if (key.escape) {
19
+ onCancel();
20
+ }
21
+ else if (key.return) {
22
+ onSelect(themeOrder[selectedIndex]);
23
+ }
24
+ else if (key.upArrow || input === 'k') {
25
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
26
+ }
27
+ else if (key.downArrow || input === 'j') {
28
+ setSelectedIndex((prev) => Math.min(themeOrder.length - 1, prev + 1));
29
+ }
30
+ });
31
+ // Calculate box dimensions
32
+ const boxWidth = Math.min(50, width - 4);
33
+ const boxHeight = Math.min(themeOrder.length + 10, height - 4); // +10 for header, preview, footer, borders
34
+ // Center the modal
35
+ const { x, y } = centerModal(boxWidth, boxHeight, width, height);
36
+ 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: [' ', "Select Theme", ' '] }) }), themeOrder.map((themeName, index) => {
37
+ const theme = themes[themeName];
38
+ const isSelected = index === selectedIndex;
39
+ const isCurrent = themeName === currentTheme;
40
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? 'cyan' : undefined, children: isSelected ? '▸ ' : ' ' }), _jsx(Text, { bold: isSelected, color: isSelected ? 'cyan' : undefined, children: theme.displayName }), isCurrent && _jsx(Text, { dimColor: true, children: " (current)" })] }, themeName));
41
+ }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Preview:" }), _jsx(ThemePreview, { theme: previewTheme })] }), _jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u2022 Enter select \u2022 Esc cancel" }) })] }) }));
42
+ }
@@ -1 +1,14 @@
1
- import{jsx as e,jsxs as o,Fragment as s}from"react/jsx-runtime";import G from"react";import{Box as n,Text as i}from"ink";import{FileList as X}from"./FileList.js";import{HistoryView as b}from"./HistoryView.js";import{CompareListView as z}from"./CompareListView.js";import{ExplorerView as H,buildBreadcrumbs as N}from"./ExplorerView.js";import{categorizeFiles as Y}from"../utils/fileCategories.js";export function TopPane({bottomTab:d,currentPane:l,terminalWidth:c,topPaneHeight:t,files:h,selectedIndex:g,fileListScrollOffset:C,stagedCount:w,onStage:y,onUnstage:a,commits:u,historySelectedIndex:A,historyScrollOffset:O,onSelectHistoryCommit:p,compareDiff:r,compareListSelection:v,compareScrollOffset:F,includeUncommitted:f,explorerCurrentPath:m="",explorerItems:I=[],explorerSelectedIndex:E=0,explorerScrollOffset:R=0,explorerIsLoading:j=!1,explorerError:L=null}){const{modified:S,untracked:k}=Y(h),B=S.length,M=k.length;return o(n,{flexDirection:"column",height:t,width:c,overflowX:"hidden",overflowY:"hidden",children:[(d==="diff"||d==="commit")&&o(s,{children:[o(n,{children:[e(i,{bold:!0,color:l==="files"?"cyan":void 0,children:"STAGING AREA"}),o(i,{dimColor:!0,children:[" ","(",B," modified, ",M," untracked, ",w," staged)"]})]}),e(X,{files:h,selectedIndex:g,isFocused:l==="files",scrollOffset:C,maxHeight:t-1,width:c,onStage:y,onUnstage:a})]}),d==="history"&&o(s,{children:[o(n,{children:[e(i,{bold:!0,color:l==="history"?"cyan":void 0,children:"COMMITS"}),o(i,{dimColor:!0,children:[" (",u.length," commits)"]})]}),e(b,{commits:u,selectedIndex:A,scrollOffset:O,maxHeight:t-1,isActive:l==="history",width:c,onSelectCommit:p})]}),d==="compare"&&o(s,{children:[o(n,{children:[e(i,{bold:!0,color:l==="compare"?"cyan":void 0,children:"COMPARE"}),e(i,{dimColor:!0,children:" (vs "}),e(i,{color:"cyan",children:r?.baseBranch??"..."}),o(i,{dimColor:!0,children:[": ",r?.commits.length??0," commits, ",r?.files.length??0," files) (b)"]}),r&&r.uncommittedCount>0&&o(s,{children:[e(i,{dimColor:!0,children:" | "}),o(i,{color:f?"magenta":"yellow",children:["[",f?"x":" ","] uncommitted"]}),e(i,{dimColor:!0,children:" (u)"})]})]}),e(z,{commits:r?.commits??[],files:r?.files??[],selectedItem:v,scrollOffset:F,maxHeight:t-1,isActive:l==="compare",width:c})]}),d==="explorer"&&o(s,{children:[o(n,{children:[e(i,{bold:!0,color:l==="explorer"?"cyan":void 0,children:"EXPLORER"}),e(i,{dimColor:!0,children:" "}),N(m).map((V,x,_)=>o(G.Fragment,{children:[e(i,{color:"blue",children:V}),x<_.length-1&&e(i,{dimColor:!0,children:" / "})]},x)),m&&e(i,{dimColor:!0,children:" /"}),!m&&e(i,{dimColor:!0,children:"(root)"})]}),e(H,{currentPath:m,items:I,selectedIndex:E,scrollOffset:R,maxHeight:t-1,isActive:l==="explorer",width:c,isLoading:j,error:L})]})]})}
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { FileList } from './FileList.js';
5
+ import { HistoryView } from './HistoryView.js';
6
+ import { CompareListView } from './CompareListView.js';
7
+ import { ExplorerView, buildBreadcrumbs } from './ExplorerView.js';
8
+ import { categorizeFiles } from '../utils/fileCategories.js';
9
+ export function TopPane({ bottomTab, currentPane, terminalWidth, topPaneHeight, files, selectedIndex, fileListScrollOffset, stagedCount, onStage, onUnstage, commits, historySelectedIndex, historyScrollOffset, onSelectHistoryCommit, compareDiff, compareListSelection, compareScrollOffset, includeUncommitted, explorerCurrentPath = '', explorerItems = [], explorerSelectedIndex = 0, explorerScrollOffset = 0, explorerIsLoading = false, explorerError = null, hideHiddenFiles = true, hideGitignored = true, }) {
10
+ const { modified, untracked } = categorizeFiles(files);
11
+ const modifiedCount = modified.length;
12
+ const untrackedCount = untracked.length;
13
+ return (_jsxs(Box, { flexDirection: "column", height: topPaneHeight, width: terminalWidth, overflowX: "hidden", overflowY: "hidden", children: [(bottomTab === 'diff' || bottomTab === 'commit') && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'files' ? 'cyan' : undefined, children: "STAGING AREA" }), _jsxs(Text, { dimColor: true, children: [' ', "(", modifiedCount, " modified, ", untrackedCount, " untracked, ", stagedCount, " staged)"] })] }), _jsx(FileList, { files: files, selectedIndex: selectedIndex, isFocused: currentPane === 'files', scrollOffset: fileListScrollOffset, maxHeight: topPaneHeight - 1, width: terminalWidth, onStage: onStage, onUnstage: onUnstage })] })), bottomTab === 'history' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'history' ? 'cyan' : undefined, children: "COMMITS" }), _jsxs(Text, { dimColor: true, children: [" (", commits.length, " commits)"] })] }), _jsx(HistoryView, { commits: commits, selectedIndex: historySelectedIndex, scrollOffset: historyScrollOffset, maxHeight: topPaneHeight - 1, isActive: currentPane === 'history', width: terminalWidth, onSelectCommit: onSelectHistoryCommit })] })), bottomTab === 'compare' && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'compare' ? 'cyan' : undefined, children: "COMPARE" }), _jsx(Text, { dimColor: true, children: " (vs " }), _jsx(Text, { color: "cyan", children: compareDiff?.baseBranch ?? '...' }), _jsxs(Text, { dimColor: true, children: [": ", compareDiff?.commits.length ?? 0, " commits, ", compareDiff?.files.length ?? 0, " files) (b)"] }), compareDiff && compareDiff.uncommittedCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { color: includeUncommitted ? 'magenta' : 'yellow', children: ["[", includeUncommitted ? 'x' : ' ', "] uncommitted"] }), _jsx(Text, { dimColor: true, children: " (u)" })] }))] }), _jsx(CompareListView, { commits: compareDiff?.commits ?? [], files: compareDiff?.files ?? [], selectedItem: compareListSelection, scrollOffset: compareScrollOffset, maxHeight: topPaneHeight - 1, isActive: currentPane === 'compare', width: terminalWidth })] })), bottomTab === 'explorer' && (_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "space-between", width: terminalWidth, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: currentPane === 'explorer' ? 'cyan' : undefined, children: "EXPLORER" }), _jsx(Text, { dimColor: true, children: " " }), buildBreadcrumbs(explorerCurrentPath).map((segment, i, arr) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: "blue", children: segment }), i < arr.length - 1 && _jsx(Text, { dimColor: true, children: " / " })] }, i))), explorerCurrentPath && _jsx(Text, { dimColor: true, children: " /" }), !explorerCurrentPath && _jsx(Text, { dimColor: true, children: "(root)" })] }), _jsx(Box, { children: (hideHiddenFiles || hideGitignored) && (_jsxs(Text, { dimColor: true, children: [hideHiddenFiles && 'H', hideGitignored && 'G'] })) })] }), _jsx(ExplorerView, { currentPath: explorerCurrentPath, items: explorerItems, selectedIndex: explorerSelectedIndex, scrollOffset: explorerScrollOffset, maxHeight: topPaneHeight - 1, isActive: currentPane === 'explorer', width: terminalWidth, isLoading: explorerIsLoading, error: explorerError })] }))] }));
14
+ }
@@ -1 +1,115 @@
1
- import{jsxs as f,jsx as e}from"react/jsx-runtime";import{useMemo as N}from"react";import{Box as m,Text as o}from"ink";import{getTheme as D}from"../themes.js";import{ScrollableList as I}from"./ScrollableList.js";import{getDisplayRowsLineNumWidth as j}from"../utils/displayRows.js";function l(n,r){return r<=0||n.length<=r?n:r<=1?"\u2026":n.slice(0,r-1)+"\u2026"}function b(n,r){return n===void 0?" ".repeat(r):String(n).padStart(r," ")}function T({row:n,lineNumWidth:r,width:g,theme:C,wrapMode:u}){const{colors:c}=C,h=g-r-5,s=g-2;switch(n.type){case"diff-header":{const t=n.content;if(t.startsWith("diff --git")){const i=t.match(/diff --git a\/.+ b\/(.+)$/);if(i){const a=s-6,d=l(i[1],a);return f(o,{color:"cyan",bold:!0,children:["\u2500\u2500 ",d," \u2500\u2500"]})}}return e(o,{dimColor:!0,children:l(t,s)})}case"diff-hunk":{const t=n.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);if(t){const i=parseInt(t[1],10),a=t[2]?parseInt(t[2],10):1,d=parseInt(t[3],10),p=t[4]?parseInt(t[4],10):1,x=t[5].trim(),k=i+a-1,B=d+p-1,S=a===1?`${i}`:`${i}-${k}`,R=p===1?`${d}`:`${d}-${B}`,y=`Lines ${S} \u2192 ${R}`,L=s-y.length-1,$=x&&L>3?" "+l(x,L):"";return f(m,{children:[e(o,{color:"cyan",dimColor:!0,children:y}),$&&e(o,{color:"gray",children:$})]})}return e(o,{color:"cyan",dimColor:!0,children:l(n.content,s)})}case"diff-add":{const t=n.isContinuation,i=t?"\xBB":"+",d=" "+(u?n.content||"":l(n.content,h)||"")||" ";return f(m,{children:[e(o,{backgroundColor:c.addBg,color:c.addLineNum,children:b(n.lineNum,r)+" "}),e(o,{backgroundColor:c.addBg,color:t?c.addLineNum:c.addSymbol,bold:!t,children:i}),e(o,{backgroundColor:c.addBg,color:c.text,children:d})]})}case"diff-del":{const t=n.isContinuation,i=t?"\xBB":"-",d=" "+(u?n.content||"":l(n.content,h)||"")||" ";return f(m,{children:[e(o,{backgroundColor:c.delBg,color:c.delLineNum,children:b(n.lineNum,r)+" "}),e(o,{backgroundColor:c.delBg,color:t?c.delLineNum:c.delSymbol,bold:!t,children:i}),e(o,{backgroundColor:c.delBg,color:c.text,children:d})]})}case"diff-context":{const i=n.isContinuation?"\xBB ":" ",a=u?n.content:l(n.content,h);return f(m,{children:[f(o,{color:c.contextLineNum,children:[b(n.lineNum,r)," "]}),e(o,{dimColor:!0,children:i}),e(o,{children:a})]})}case"commit-header":return e(o,{color:"yellow",children:l(n.content,s)});case"commit-message":return e(o,{children:l(n.content,s)});case"spacer":return e(o,{children:" "})}}export function UnifiedDiffView({rows:n,maxHeight:r,scrollOffset:g,theme:C,width:u,wrapMode:c=!1}){const h=N(()=>D(C),[C]),s=N(()=>j(n),[n]);return n.length===0?e(m,{paddingX:1,children:e(o,{dimColor:!0,children:"No diff to display"})}):e(m,{flexDirection:"column",paddingX:1,width:u,children:e(I,{items:n,maxHeight:r,scrollOffset:g,getKey:(t,i)=>`row-${i}`,renderItem:t=>e(T,{row:t,lineNumWidth:s,width:u,theme:h,wrapMode:c})})})}export function getUnifiedDiffTotalRows(n){return n.length}
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { getTheme } from '../themes.js';
5
+ import { ScrollableList } from './ScrollableList.js';
6
+ import { getDisplayRowsLineNumWidth } from '../utils/displayRows.js';
7
+ // Truncate string to fit within maxWidth, adding ellipsis if needed
8
+ function truncate(str, maxWidth) {
9
+ if (maxWidth <= 0 || str.length <= maxWidth)
10
+ return str;
11
+ if (maxWidth <= 1)
12
+ return '\u2026';
13
+ return str.slice(0, maxWidth - 1) + '\u2026';
14
+ }
15
+ // Format line number with padding
16
+ function formatLineNum(lineNum, width) {
17
+ if (lineNum === undefined)
18
+ return ' '.repeat(width);
19
+ return String(lineNum).padStart(width, ' ');
20
+ }
21
+ function DisplayRowRenderer({ row, lineNumWidth, width, theme, wrapMode, }) {
22
+ const { colors } = theme;
23
+ // Available width for content: width - paddingX(1) - lineNum - space(1) - symbol(1) - space(1) - paddingX(1)
24
+ const contentWidth = width - lineNumWidth - 5;
25
+ // Width for headers (just subtract paddingX on each side)
26
+ const headerWidth = width - 2;
27
+ switch (row.type) {
28
+ case 'diff-header': {
29
+ // Extract file path from diff --git and show as clean separator
30
+ const content = row.content;
31
+ if (content.startsWith('diff --git')) {
32
+ const match = content.match(/diff --git a\/.+ b\/(.+)$/);
33
+ if (match) {
34
+ const maxPathLen = headerWidth - 6; // "── " + " ──"
35
+ const path = truncate(match[1], maxPathLen);
36
+ return (_jsxs(Text, { color: "cyan", bold: true, children: ["\u2500\u2500 ", path, " \u2500\u2500"] }));
37
+ }
38
+ }
39
+ return _jsx(Text, { dimColor: true, children: truncate(content, headerWidth) });
40
+ }
41
+ case 'diff-hunk': {
42
+ // Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ context
43
+ const match = row.content.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/);
44
+ if (match) {
45
+ const oldStart = parseInt(match[1], 10);
46
+ const oldCount = match[2] ? parseInt(match[2], 10) : 1;
47
+ const newStart = parseInt(match[3], 10);
48
+ const newCount = match[4] ? parseInt(match[4], 10) : 1;
49
+ const context = match[5].trim();
50
+ const oldEnd = oldStart + oldCount - 1;
51
+ const newEnd = newStart + newCount - 1;
52
+ const oldRange = oldCount === 1 ? `${oldStart}` : `${oldStart}-${oldEnd}`;
53
+ const newRange = newCount === 1 ? `${newStart}` : `${newStart}-${newEnd}`;
54
+ const rangeText = `Lines ${oldRange} \u2192 ${newRange}`;
55
+ const contextMaxLen = headerWidth - rangeText.length - 1;
56
+ const truncatedContext = context && contextMaxLen > 3 ? ' ' + truncate(context, contextMaxLen) : '';
57
+ return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", dimColor: true, children: rangeText }), truncatedContext && _jsx(Text, { color: "gray", children: truncatedContext })] }));
58
+ }
59
+ return (_jsx(Text, { color: "cyan", dimColor: true, children: truncate(row.content, headerWidth) }));
60
+ }
61
+ case 'diff-add': {
62
+ const isCont = row.isContinuation;
63
+ // Use » for continuation - it's single-width and renders background correctly
64
+ const symbol = isCont ? '\u00bb' : '+';
65
+ const rawContent = wrapMode ? row.content || '' : truncate(row.content, contentWidth) || '';
66
+ // Always prepend space to content
67
+ const content = ' ' + rawContent || ' ';
68
+ return (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.addBg, color: colors.addLineNum, children: formatLineNum(row.lineNum, lineNumWidth) + ' ' }), _jsx(Text, { backgroundColor: colors.addBg, color: isCont ? colors.addLineNum : colors.addSymbol, bold: !isCont, children: symbol }), _jsx(Text, { backgroundColor: colors.addBg, color: colors.text, children: content })] }));
69
+ }
70
+ case 'diff-del': {
71
+ const isCont = row.isContinuation;
72
+ // Use » for continuation - it's single-width and renders background correctly
73
+ const symbol = isCont ? '\u00bb' : '-';
74
+ const rawContent = wrapMode ? row.content || '' : truncate(row.content, contentWidth) || '';
75
+ // Always prepend space to content
76
+ const content = ' ' + rawContent || ' ';
77
+ return (_jsxs(Box, { children: [_jsx(Text, { backgroundColor: colors.delBg, color: colors.delLineNum, children: formatLineNum(row.lineNum, lineNumWidth) + ' ' }), _jsx(Text, { backgroundColor: colors.delBg, color: isCont ? colors.delLineNum : colors.delSymbol, bold: !isCont, children: symbol }), _jsx(Text, { backgroundColor: colors.delBg, color: colors.text, children: content })] }));
78
+ }
79
+ case 'diff-context': {
80
+ const isCont = row.isContinuation;
81
+ // Use » for continuation - it's single-width and renders correctly
82
+ const symbol = isCont ? '\u00bb ' : ' ';
83
+ const content = wrapMode ? row.content : truncate(row.content, contentWidth);
84
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: colors.contextLineNum, children: [formatLineNum(row.lineNum, lineNumWidth), " "] }), _jsx(Text, { dimColor: true, children: symbol }), _jsx(Text, { children: content })] }));
85
+ }
86
+ case 'commit-header':
87
+ return _jsx(Text, { color: "yellow", children: truncate(row.content, headerWidth) });
88
+ case 'commit-message':
89
+ return _jsx(Text, { children: truncate(row.content, headerWidth) });
90
+ case 'spacer':
91
+ return _jsx(Text, { children: " " });
92
+ }
93
+ }
94
+ /**
95
+ * The ONE diff renderer used by all tabs.
96
+ * Every row = exactly 1 terminal row.
97
+ * No variable heights, no complexity.
98
+ */
99
+ export function UnifiedDiffView({ rows, maxHeight, scrollOffset, theme: themeName, width, wrapMode = false, }) {
100
+ const theme = useMemo(() => getTheme(themeName), [themeName]);
101
+ const lineNumWidth = useMemo(() => getDisplayRowsLineNumWidth(rows), [rows]);
102
+ if (rows.length === 0) {
103
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "No diff to display" }) }));
104
+ }
105
+ return (_jsx(Box, { flexDirection: "column", paddingX: 1, width: width, children: _jsx(ScrollableList, { items: rows, maxHeight: maxHeight, scrollOffset: scrollOffset, getKey: (_, i) => `row-${i}`,
106
+ // NO getItemHeight - all rows are 1 line
107
+ renderItem: (row) => (_jsx(DisplayRowRenderer, { row: row, lineNumWidth: lineNumWidth, width: width, theme: theme, wrapMode: wrapMode })) }) }));
108
+ }
109
+ /**
110
+ * Get total row count for scroll calculation.
111
+ * Since every row = 1 terminal row, this is just rows.length.
112
+ */
113
+ export function getUnifiedDiffTotalRows(rows) {
114
+ return rows.length;
115
+ }