From 689a8bdab61f2c357d04828b56886b2c8a8f77a6 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 18:47:44 +0000 Subject: [PATCH 01/18] Add interactive TUI mode with file tree side panel Co-Authored-By: Claude Opus 4.6 --- src/getConfig.ts | 2 + src/getGitConfig.test.ts | 2 + src/getGitConfig.ts | 2 + src/index.test.ts | 1 + src/index.ts | 49 +++++-- src/iterSideBySideDiffs.ts | 269 +++++++++++++++++++++++++++++++++++ src/previewTheme.ts | 1 + src/themes.ts | 28 +++- src/tui/DiffViewPanel.ts | 87 +++++++++++ src/tui/FileTreePanel.ts | 127 +++++++++++++++++ src/tui/InputHandler.ts | 40 ++++++ src/tui/Screen.ts | 123 ++++++++++++++++ src/tui/TuiApp.ts | 255 +++++++++++++++++++++++++++++++++ src/tui/ansi.ts | 23 +++ src/tui/collectDiffData.ts | 65 +++++++++ src/tui/sync.ts | 56 ++++++++ themes/arctic.json | 10 ++ themes/dark.json | 13 +- themes/github-dark-dim.json | 10 ++ themes/github-light.json | 10 ++ themes/light.json | 10 ++ themes/monochrome-dark.json | 10 ++ themes/monochrome-light.json | 10 +- themes/solarized-dark.json | 12 +- themes/solarized-light.json | 12 +- 25 files changed, 1208 insertions(+), 19 deletions(-) create mode 100644 src/tui/DiffViewPanel.ts create mode 100644 src/tui/FileTreePanel.ts create mode 100644 src/tui/InputHandler.ts create mode 100644 src/tui/Screen.ts create mode 100644 src/tui/TuiApp.ts create mode 100644 src/tui/ansi.ts create mode 100644 src/tui/collectDiffData.ts create mode 100644 src/tui/sync.ts diff --git a/src/getConfig.ts b/src/getConfig.ts index b63b5c6..97c66d7 100644 --- a/src/getConfig.ts +++ b/src/getConfig.ts @@ -6,12 +6,14 @@ export type Config = Theme & { MIN_LINE_WIDTH: number; WRAP_LINES: boolean; HIGHLIGHT_LINE_CHANGES: boolean; + INTERACTIVE: boolean; }; export const CONFIG_DEFAULTS: Omit = { MIN_LINE_WIDTH: 80, WRAP_LINES: true, HIGHLIGHT_LINE_CHANGES: true, + INTERACTIVE: false, }; export function getConfig(gitConfig: GitConfig): Config { diff --git a/src/getGitConfig.test.ts b/src/getGitConfig.test.ts index 559a9f8..bdf3caa 100644 --- a/src/getGitConfig.test.ts +++ b/src/getGitConfig.test.ts @@ -12,6 +12,7 @@ const DEFAULT_CONFIG: GitConfig = { MIN_LINE_WIDTH: DEFAULT_MIN_LINE_WIDTH, THEME_NAME: DEFAULT_THEME_NAME, THEME_DIRECTORY: DEFAULT_THEME_DIRECTORY, + INTERACTIVE: false, }; describe('getGitConfig', () => { @@ -36,6 +37,7 @@ split-diffs.syntax-highlighting-theme=dark-plus THEME_NAME: 'arctic', THEME_DIRECTORY: '/tmp', SYNTAX_HIGHLIGHTING_THEME: 'dark-plus', + INTERACTIVE: false, }); }); diff --git a/src/getGitConfig.ts b/src/getGitConfig.ts index 5c0107c..2a04231 100644 --- a/src/getGitConfig.ts +++ b/src/getGitConfig.ts @@ -8,6 +8,7 @@ export type GitConfig = { THEME_DIRECTORY: string; THEME_NAME: string; SYNTAX_HIGHLIGHTING_THEME?: string; + INTERACTIVE: boolean; }; export const DEFAULT_MIN_LINE_WIDTH = 80; @@ -57,5 +58,6 @@ export function getGitConfig(configString: string): GitConfig { rawConfig['theme-directory'] ?? DEFAULT_THEME_DIRECTORY, THEME_NAME: rawConfig['theme-name'] ?? DEFAULT_THEME_NAME, SYNTAX_HIGHLIGHTING_THEME: rawConfig['syntax-highlighting-theme'], + INTERACTIVE: rawConfig['interactive'] === 'true', }; } diff --git a/src/index.test.ts b/src/index.test.ts index 31025bf..37dc717 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -24,6 +24,7 @@ const TEST_CONFIG: Config = { MIN_LINE_WIDTH: 60, WRAP_LINES: false, HIGHLIGHT_LINE_CHANGES: false, + INTERACTIVE: false, ...TEST_THEME, }; diff --git a/src/index.ts b/src/index.ts index e1a0859..e7e6adc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,26 +7,47 @@ import { getContextForConfig } from './context'; import { getGitConfig } from './getGitConfig'; import { transformContentsStreaming } from './transformContentsStreaming'; import { getConfig } from './getConfig'; +import { TuiApp } from './tui/TuiApp'; const execAsync = util.promisify(exec); +const TREE_WIDTH = 30; +const BORDER_WIDTH = 1; + async function main() { const { stdout: gitConfigString } = await execAsync('git config -l'); const gitConfig = getGitConfig(gitConfigString); const config = getConfig(gitConfig); - const context = await getContextForConfig( - config, - chalk, - terminalSize().columns - ); - await transformContentsStreaming(context, process.stdin, process.stdout); - - // Ensure stdout is fully flushed before exiting - // This is critical when piping to `less` - if we exit before stdout is drained, - // less may not receive all data or may receive it in a broken state - if (process.stdout.writableNeedDrain) { - await new Promise((resolve) => { - process.stdout.once('drain', resolve); - }); + + const isInteractive = + process.argv.includes('--interactive') || + process.argv.includes('-i') || + config.INTERACTIVE; + + const termCols = terminalSize().columns; + const screenWidth = isInteractive + ? termCols - TREE_WIDTH - BORDER_WIDTH + : termCols; + + const context = await getContextForConfig(config, chalk, screenWidth); + + if (isInteractive) { + const app = new TuiApp(); + await app.run(context, process.stdin); + } else { + await transformContentsStreaming( + context, + process.stdin, + process.stdout + ); + + // Ensure stdout is fully flushed before exiting + // This is critical when piping to `less` - if we exit before stdout is drained, + // less may not receive all data or may receive it in a broken state + if (process.stdout.writableNeedDrain) { + await new Promise((resolve) => { + process.stdout.once('drain', resolve); + }); + } } } diff --git a/src/iterSideBySideDiffs.ts b/src/iterSideBySideDiffs.ts index ae38fed..477d1b3 100644 --- a/src/iterSideBySideDiffs.ts +++ b/src/iterSideBySideDiffs.ts @@ -322,3 +322,272 @@ export async function* iterSideBySideDiffs( yield applyFormatting(context, formattedString); } } + +export type DiffEvent = + | { type: 'line'; content: FormattedString } + | { type: 'file-start'; fileNameA: string; fileNameB: string }; + +export async function* iterSideBySideDiffsWithEvents( + context: Context, + lines: AsyncIterable +): AsyncIterable { + const { HORIZONTAL_SEPARATOR } = context; + + let state: State = 'unknown'; + let isFirstCommitBodyLine = false; + let fileNameA: string = ''; + let fileNameB: string = ''; + let hunkParts: HunkPart[] = []; + let hunkHeaderLine: string = ''; + + function* yieldFileNameLines() { + yield* iterFormatFileName(context, fileNameA, fileNameB); + } + + async function* yieldHunkLines( + diffType: 'unified-diff' | 'combined-diff' + ) { + yield* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts); + for (const hunkPart of hunkParts) { + hunkPart.startLineNo = -1; + hunkPart.lines = []; + } + } + + async function* flushPendingEvents(): AsyncIterable { + if (state === 'unified-diff' || state === 'combined-diff') { + yield { + type: 'file-start', + fileNameA, + fileNameB, + }; + for (const line of yieldFileNameLines()) { + yield { type: 'line', content: line }; + } + } else if (state === 'unified-diff-hunk-body') { + for await (const line of yieldHunkLines('unified-diff')) { + yield { type: 'line', content: line }; + } + } else if (state === 'combined-diff-hunk-body') { + for await (const line of yieldHunkLines('combined-diff')) { + yield { type: 'line', content: line }; + } + } + } + + for await (const rawLine of lines) { + const line = rawLine.replace(ANSI_COLOR_CODE_REGEX, ''); + + let nextState: State | null = null; + if (line.startsWith('commit ')) { + nextState = 'commit-header'; + } else if (state === 'commit-header' && line.startsWith(' ')) { + nextState = 'commit-body'; + } else if (line.startsWith('diff --git')) { + nextState = 'unified-diff'; + } else if (line.startsWith('@@ ')) { + nextState = 'unified-diff-hunk-header'; + } else if (state === 'unified-diff-hunk-header') { + nextState = 'unified-diff-hunk-body'; + } else if ( + line.startsWith('diff --cc') || + line.startsWith('diff --combined') + ) { + nextState = 'combined-diff'; + } else if (COMBINED_HUNK_HEADER_START_REGEX.test(line)) { + nextState = 'combined-diff-hunk-header'; + } else if (state === 'combined-diff-hunk-header') { + nextState = 'combined-diff-hunk-body'; + } else if ( + state === 'commit-body' && + line.length > 0 && + !line.startsWith(' ') + ) { + nextState = 'unknown'; + } + + if (nextState) { + yield* flushPendingEvents(); + + switch (nextState) { + case 'commit-header': + if ( + state === 'unified-diff-hunk-header' || + state === 'unified-diff-hunk-body' + ) { + yield { + type: 'line', + content: HORIZONTAL_SEPARATOR, + }; + } + break; + case 'unified-diff': + fileNameA = ''; + fileNameB = ''; + break; + case 'unified-diff-hunk-header': + hunkParts = [ + { fileName: fileNameA, startLineNo: -1, lines: [] }, + { fileName: fileNameB, startLineNo: -1, lines: [] }, + ]; + break; + case 'commit-body': + isFirstCommitBodyLine = true; + break; + } + + state = nextState; + } + + switch (state) { + case 'unknown': { + yield { type: 'line', content: T().appendString(rawLine) }; + break; + } + case 'commit-header': { + for (const l of iterFormatCommitHeaderLine(context, line)) { + yield { type: 'line', content: l }; + } + break; + } + case 'commit-body': { + for (const l of iterFormatCommitBodyLine( + context, + line, + isFirstCommitBodyLine + )) { + yield { type: 'line', content: l }; + } + isFirstCommitBodyLine = false; + break; + } + case 'unified-diff': + case 'combined-diff': { + if (line.startsWith('--- a/')) { + fileNameA = line.slice('--- a/'.length); + } else if (line.startsWith('+++ b/')) { + fileNameB = line.slice('+++ b/'.length); + } else if (line.startsWith('--- ')) { + fileNameA = line.slice('--- '.length); + if (fileNameA === '/dev/null') { + fileNameA = ''; + } + } else if (line.startsWith('+++ ')) { + fileNameB = line.slice('+++ '.length); + if (fileNameB === '/dev/null') { + fileNameB = ''; + } + } else if (line.startsWith('rename from ')) { + fileNameA = line.slice('rename from '.length); + } else if (line.startsWith('rename to ')) { + fileNameB = line.slice('rename to '.length); + } else if (line.startsWith('Binary files')) { + const match = line.match(BINARY_FILES_DIFF_REGEX); + if (match) { + [, fileNameA, fileNameB] = match; + } + } + break; + } + case 'unified-diff-hunk-header': { + const hunkHeaderStart = line.indexOf('@@ '); + const hunkHeaderEnd = line.indexOf(' @@', hunkHeaderStart + 1); + assert.ok(hunkHeaderStart >= 0); + assert.ok(hunkHeaderEnd > hunkHeaderStart); + const hunkHeader = line.slice( + hunkHeaderStart + 3, + hunkHeaderEnd + ); + hunkHeaderLine = line; + const [aHeader, bHeader] = hunkHeader.split(' '); + const [startAString] = aHeader.split(','); + const [startBString] = bHeader.split(','); + assert.ok(startAString.startsWith('-')); + hunkParts[0].startLineNo = parseInt(startAString.slice(1), 10); + assert.ok(startBString.startsWith('+')); + hunkParts[1].startLineNo = parseInt(startBString.slice(1), 10); + break; + } + case 'unified-diff-hunk-body': { + const [{ lines: hunkLinesA }, { lines: hunkLinesB }] = + hunkParts; + if (line.startsWith('-')) { + hunkLinesA.push(line); + } else if (line.startsWith('+')) { + hunkLinesB.push(line); + } else { + while (hunkLinesA.length < hunkLinesB.length) { + hunkLinesA.push(null); + } + while (hunkLinesB.length < hunkLinesA.length) { + hunkLinesB.push(null); + } + hunkLinesA.push(line); + hunkLinesB.push(line); + } + break; + } + case 'combined-diff-hunk-header': { + const match = COMBINED_HUNK_HEADER_START_REGEX.exec(line); + assert.ok(match); + const hunkHeaderStart = + match.index + match[0].length; + const hunkHeaderEnd = line.lastIndexOf(' ' + match[1]); + assert.ok(hunkHeaderStart >= 0); + assert.ok(hunkHeaderEnd > hunkHeaderStart); + const hunkHeader = line.slice(hunkHeaderStart, hunkHeaderEnd); + hunkHeaderLine = line; + const fileRanges = hunkHeader.split(' '); + hunkParts = []; + for (let i = 0; i < fileRanges.length; i++) { + const fileRange = fileRanges[i]; + const [fileRangeStart] = fileRange.slice(1).split(','); + hunkParts.push({ + fileName: + i === fileRanges.length - 1 ? fileNameB : fileNameA, + startLineNo: parseInt(fileRangeStart, 10), + lines: [], + }); + } + break; + } + case 'combined-diff-hunk-body': { + const linePrefix = line.slice(0, hunkParts.length - 1); + const lineSuffix = line.slice(hunkParts.length - 1); + const isLineAdded = linePrefix.includes('+'); + const isLineRemoved = linePrefix.includes('-'); + let i = 0; + while (i < hunkParts.length - 1) { + const hunkPart = hunkParts[i]; + const partPrefix = linePrefix[i]; + if (isLineAdded) { + if (partPrefix === '+') { + hunkPart.lines.push(null); + } else { + hunkPart.lines.push('+' + lineSuffix); + } + } else if (isLineRemoved) { + if (partPrefix === '-') { + hunkPart.lines.push('-' + lineSuffix); + } else { + hunkPart.lines.push(null); + } + } else { + hunkPart.lines.push(' ' + lineSuffix); + } + i++; + } + if (isLineRemoved) { + hunkParts[i].lines.push('-' + lineSuffix); + } else if (isLineAdded) { + hunkParts[i].lines.push('+' + lineSuffix); + } else { + hunkParts[i].lines.push(' ' + lineSuffix); + } + break; + } + } + } + + yield* flushPendingEvents(); +} diff --git a/src/previewTheme.ts b/src/previewTheme.ts index 298099f..f68096b 100644 --- a/src/previewTheme.ts +++ b/src/previewTheme.ts @@ -12,6 +12,7 @@ const CONFIG = { MIN_LINE_WIDTH: 40, WRAP_LINES: true, HIGHLIGHT_LINE_CHANGES: true, + INTERACTIVE: false, }; async function previewTheme( diff --git a/src/themes.ts b/src/themes.ts index ed8ff83..2ba9120 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -46,8 +46,28 @@ export enum ThemeColorName { UNMODIFIED_LINE_COLOR = 'UNMODIFIED_LINE_COLOR', UNMODIFIED_LINE_NO_COLOR = 'UNMODIFIED_LINE_NO_COLOR', MISSING_LINE_COLOR = 'MISSING_LINE_COLOR', + FILE_TREE_COLOR = 'FILE_TREE_COLOR', + FILE_TREE_SELECTED_COLOR = 'FILE_TREE_SELECTED_COLOR', + FILE_TREE_BORDER_COLOR = 'FILE_TREE_BORDER_COLOR', } +const OPTIONAL_THEME_COLORS: Set = new Set([ + ThemeColorName.FILE_TREE_COLOR, + ThemeColorName.FILE_TREE_SELECTED_COLOR, + ThemeColorName.FILE_TREE_BORDER_COLOR, +]); + +const OPTIONAL_THEME_COLOR_DEFAULTS: Partial< + Record +> = { + [ThemeColorName.FILE_TREE_COLOR]: { color: '#cccccc' }, + [ThemeColorName.FILE_TREE_SELECTED_COLOR]: { modifiers: ['inverse'] }, + [ThemeColorName.FILE_TREE_BORDER_COLOR]: { + color: '#ffdd9966', + modifiers: ['dim'], + }, +}; + export type ThemeDefinition = { SYNTAX_HIGHLIGHTING_THEME?: shiki.BundledTheme; } & { @@ -177,8 +197,14 @@ export function loadTheme(themesDir: string, themeName: string): Theme { const themeColorNames = Object.keys(ThemeColorName) as ThemeColorName[]; for (const variableName of themeColorNames) { - const value = themeDefinition[variableName]; + const value = + themeDefinition[variableName] ?? + OPTIONAL_THEME_COLOR_DEFAULTS[variableName]; if (!value) { + if (OPTIONAL_THEME_COLORS.has(variableName)) { + theme[variableName] = {}; + continue; + } assert.fail(`${variableName} is missing in theme`); } theme[variableName] = parseColorDefinition(value); diff --git a/src/tui/DiffViewPanel.ts b/src/tui/DiffViewPanel.ts new file mode 100644 index 0000000..5e1d3fb --- /dev/null +++ b/src/tui/DiffViewPanel.ts @@ -0,0 +1,87 @@ +import { Screen, truncateAnsi } from './Screen'; +import { RESET } from './ansi'; + +export class DiffViewPanel { + private lines: string[]; + private width: number; + private height: number; + scrollOffset: number = 0; + + constructor(lines: string[], width: number, height: number) { + this.lines = lines; + this.width = width; + this.height = height; + } + + resize(width: number, height: number): void { + this.width = width; + this.height = height; + this.clampScroll(); + } + + scrollUp(n: number = 1): void { + this.scrollOffset = Math.max(0, this.scrollOffset - n); + } + + scrollDown(n: number = 1): void { + this.scrollOffset = Math.min(this.maxScroll(), this.scrollOffset + n); + } + + pageUp(): void { + this.scrollUp(this.height - 1); + } + + pageDown(): void { + this.scrollDown(this.height - 1); + } + + scrollToLine(lineIndex: number): void { + this.scrollOffset = Math.max( + 0, + Math.min(lineIndex, this.maxScroll()) + ); + } + + scrollToTop(): void { + this.scrollOffset = 0; + } + + scrollToBottom(): void { + this.scrollOffset = this.maxScroll(); + } + + getTopVisibleLine(): number { + return this.scrollOffset; + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.height); + } + + private clampScroll(): void { + this.scrollOffset = Math.max( + 0, + Math.min(this.scrollOffset, this.maxScroll()) + ); + } + + render(screen: Screen, startCol: number, startRow: number): void { + for (let row = 0; row < this.height; row++) { + const lineIndex = this.scrollOffset + row; + const screenRow = startRow + row; + + if (lineIndex >= this.lines.length) { + screen.clearLine(screenRow); + continue; + } + + const line = this.lines[lineIndex]; + screen.writeAt( + screenRow, + startCol, + truncateAnsi(line, this.width) + RESET, + this.width + ); + } + } +} diff --git a/src/tui/FileTreePanel.ts b/src/tui/FileTreePanel.ts new file mode 100644 index 0000000..cdb7e6b --- /dev/null +++ b/src/tui/FileTreePanel.ts @@ -0,0 +1,127 @@ +import { Context } from '../context'; +import { applyFormatting, T } from '../formattedString'; +import { Screen } from './Screen'; +import { DiffFile } from './collectDiffData'; +import { RESET } from './ansi'; + +export class FileTreePanel { + private files: DiffFile[]; + private width: number; + private height: number; + private context: Context; + selectedIndex: number = 0; + scrollOffset: number = 0; + + constructor( + files: DiffFile[], + width: number, + height: number, + context: Context + ) { + this.files = files; + this.width = width; + this.height = height; + this.context = context; + } + + resize(width: number, height: number): void { + this.width = width; + this.height = height; + this.clampScroll(); + } + + moveUp(): void { + if (this.selectedIndex > 0) { + this.selectedIndex--; + this.ensureVisible(); + } + } + + moveDown(): void { + if (this.selectedIndex < this.files.length - 1) { + this.selectedIndex++; + this.ensureVisible(); + } + } + + selectFile(index: number): void { + if (index >= 0 && index < this.files.length) { + this.selectedIndex = index; + this.ensureVisible(); + } + } + + getSelectedFile(): DiffFile | undefined { + return this.files[this.selectedIndex]; + } + + private ensureVisible(): void { + if (this.selectedIndex < this.scrollOffset) { + this.scrollOffset = this.selectedIndex; + } else if (this.selectedIndex >= this.scrollOffset + this.height) { + this.scrollOffset = this.selectedIndex - this.height + 1; + } + } + + private clampScroll(): void { + const maxScroll = Math.max(0, this.files.length - this.height); + this.scrollOffset = Math.min(this.scrollOffset, maxScroll); + this.scrollOffset = Math.max(0, this.scrollOffset); + } + + render(screen: Screen, startCol: number, startRow: number, focused: boolean): void { + const { FILE_TREE_COLOR, FILE_TREE_SELECTED_COLOR } = this.context; + + for (let row = 0; row < this.height; row++) { + const fileIndex = this.scrollOffset + row; + const screenRow = startRow + row; + + if (fileIndex >= this.files.length) { + // Empty row — fill with tree background + const emptyLine = T() + .fillWidth(this.width, ' ') + .addSpan(0, this.width, FILE_TREE_COLOR); + screen.writeAt( + screenRow, + startCol, + applyFormatting(this.context, emptyLine), + this.width + ); + continue; + } + + const file = this.files[fileIndex]; + const isSelected = fileIndex === this.selectedIndex; + + const statusChar = this.getStatusChar(file); + const label = ` ${statusChar} ${file.displayName} `; + + const line = T() + .appendString(label) + .fillWidth(this.width, ' ') + .addSpan(0, this.width, FILE_TREE_COLOR); + + if (isSelected) { + line.addSpan(0, this.width, FILE_TREE_SELECTED_COLOR); + if (focused) { + // Add a marker for focused+selected + // The inverse modifier already handles highlight + } + } + + screen.writeAt( + screenRow, + startCol, + applyFormatting(this.context, line) + RESET, + this.width + ); + } + } + + private getStatusChar(file: DiffFile): string { + if (!file.fileNameA) return 'A'; + if (!file.fileNameB) return 'D'; + if (file.fileNameA !== file.fileNameB) return 'R'; + return 'M'; + } +} diff --git a/src/tui/InputHandler.ts b/src/tui/InputHandler.ts new file mode 100644 index 0000000..ab1e465 --- /dev/null +++ b/src/tui/InputHandler.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import * as readline from 'readline'; +import * as tty from 'tty'; + +export type KeyHandler = (key: string, ctrl: boolean) => void; + +export class InputHandler { + private ttyStream: tty.ReadStream | null = null; + private handler: KeyHandler; + + constructor(handler: KeyHandler) { + this.handler = handler; + } + + start(): void { + const fd = fs.openSync('/dev/tty', 'r'); + this.ttyStream = new tty.ReadStream(fd); + this.ttyStream.setEncoding('utf-8'); + this.ttyStream.setRawMode(true); + + readline.emitKeypressEvents(this.ttyStream); + + this.ttyStream.on('keypress', (_str: string, key: readline.Key) => { + if (!key) return; + const ctrl = key.ctrl === true; + const name = key.name ?? key.sequence ?? ''; + this.handler(name, ctrl); + }); + + this.ttyStream.resume(); + } + + stop(): void { + if (this.ttyStream) { + this.ttyStream.setRawMode(false); + this.ttyStream.destroy(); + this.ttyStream = null; + } + } +} diff --git a/src/tui/Screen.ts b/src/tui/Screen.ts new file mode 100644 index 0000000..9bafcd1 --- /dev/null +++ b/src/tui/Screen.ts @@ -0,0 +1,123 @@ +import ansiRegex from 'ansi-regex'; +import * as process from 'process'; +import { + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, + HIDE_CURSOR, + SHOW_CURSOR, + CLEAR_SCREEN, + CLEAR_LINE, + moveTo, + RESET, +} from './ansi'; + +const ANSI_REGEX = ansiRegex(); + +export class Screen { + rows: number; + cols: number; + private buffer: string = ''; + private resizeHandler: (() => void) | null = null; + + constructor(rows: number, cols: number) { + this.rows = rows; + this.cols = cols; + } + + enter(): void { + this.write(ENTER_ALT_SCREEN + HIDE_CURSOR + CLEAR_SCREEN); + this.flush(); + } + + exit(): void { + this.write(SHOW_CURSOR + EXIT_ALT_SCREEN); + this.flush(); + } + + onResize(handler: () => void): void { + this.resizeHandler = () => { + this.rows = process.stdout.rows ?? this.rows; + this.cols = process.stdout.columns ?? this.cols; + handler(); + }; + process.stdout.on('resize', this.resizeHandler); + } + + removeResizeListener(): void { + if (this.resizeHandler) { + process.stdout.removeListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + } + + writeAt(row: number, col: number, text: string, maxWidth: number): void { + this.buffer += moveTo(row, col); + this.buffer += truncateAnsi(text, maxWidth); + } + + clearLine(row: number): void { + this.buffer += moveTo(row, 0) + CLEAR_LINE; + } + + drawVerticalBorder(col: number, startRow: number, endRow: number, style: string): void { + for (let r = startRow; r < endRow; r++) { + this.buffer += moveTo(r, col) + style + '│' + RESET; + } + } + + flush(): void { + if (this.buffer.length > 0) { + process.stdout.write(this.buffer); + this.buffer = ''; + } + } + + private write(data: string): void { + this.buffer += data; + } +} + +/** + * Truncate an ANSI-formatted string to a visible width. + * Preserves escape sequences, counts only visible characters. + */ +export function truncateAnsi(str: string, maxWidth: number): string { + let visibleLen = 0; + let result = ''; + let lastIndex = 0; + + ANSI_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = ANSI_REGEX.exec(str)) !== null) { + // Text before this escape sequence + const textBefore = str.slice(lastIndex, match.index); + const remaining = maxWidth - visibleLen; + + if (textBefore.length <= remaining) { + result += textBefore; + visibleLen += textBefore.length; + } else { + result += textBefore.slice(0, remaining); + visibleLen = maxWidth; + result += RESET; + return result; + } + + // Always include the escape sequence + result += match[0]; + lastIndex = match.index + match[0].length; + } + + // Remaining text after last escape + const tail = str.slice(lastIndex); + const remaining = maxWidth - visibleLen; + if (tail.length <= remaining) { + result += tail; + } else { + result += tail.slice(0, remaining); + result += RESET; + } + + return result; +} diff --git a/src/tui/TuiApp.ts b/src/tui/TuiApp.ts new file mode 100644 index 0000000..1de0f14 --- /dev/null +++ b/src/tui/TuiApp.ts @@ -0,0 +1,255 @@ +import { Readable } from 'stream'; +import * as process from 'process'; +import { Context } from '../context'; +import { applyFormatting, T } from '../formattedString'; +import { Screen } from './Screen'; +import { InputHandler } from './InputHandler'; +import { FileTreePanel } from './FileTreePanel'; +import { DiffViewPanel } from './DiffViewPanel'; +import { collectDiffData, DiffData } from './collectDiffData'; +import { syncTreeToDiff, syncDiffToTree } from './sync'; +import { RESET } from './ansi'; + +const TREE_WIDTH = 30; +const BORDER_WIDTH = 1; + +type FocusPanel = 'tree' | 'diff'; + +export class TuiApp { + private screen!: Screen; + private input!: InputHandler; + private tree!: FileTreePanel; + private diff!: DiffViewPanel; + private data!: DiffData; + private context!: Context; + private focus: FocusPanel = 'tree'; + private resolve!: () => void; + + async run(context: Context, stdin: Readable): Promise { + this.context = context; + + // Consume all diff data from stdin first + this.data = await collectDiffData(context, stdin); + + if (this.data.files.length === 0 && this.data.allRenderedLines.length === 0) { + return; + } + + const rows = process.stdout.rows ?? 24; + const cols = process.stdout.columns ?? 80; + + this.screen = new Screen(rows, cols); + this.screen.enter(); + + const viewHeight = rows; + const diffWidth = cols - TREE_WIDTH - BORDER_WIDTH; + + this.tree = new FileTreePanel( + this.data.files, + TREE_WIDTH, + viewHeight, + context + ); + this.diff = new DiffViewPanel( + this.data.allRenderedLines, + diffWidth, + viewHeight + ); + + // Sync initial position + if (this.data.files.length > 0) { + syncTreeToDiff(this.tree, this.diff, this.data.fileBoundaries); + } + + this.screen.onResize(() => this.handleResize()); + this.input = new InputHandler((key, ctrl) => + this.handleKey(key, ctrl) + ); + this.input.start(); + + this.render(); + + return new Promise((resolve) => { + this.resolve = resolve; + }); + } + + private handleResize(): void { + const rows = process.stdout.rows ?? 24; + const cols = process.stdout.columns ?? 80; + this.screen.rows = rows; + this.screen.cols = cols; + + const viewHeight = rows; + const diffWidth = cols - TREE_WIDTH - BORDER_WIDTH; + + this.tree.resize(TREE_WIDTH, viewHeight); + this.diff.resize(diffWidth, viewHeight); + this.render(); + } + + private handleKey(key: string, ctrl: boolean): void { + if (key === 'q' || (ctrl && key === 'c')) { + this.quit(); + return; + } + + if (key === 'tab') { + this.focus = this.focus === 'tree' ? 'diff' : 'tree'; + this.render(); + return; + } + + if (this.focus === 'tree') { + this.handleTreeKey(key); + } else { + this.handleDiffKey(key); + } + } + + private handleTreeKey(key: string): void { + switch (key) { + case 'j': + case 'down': + this.tree.moveDown(); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + case 'k': + case 'up': + this.tree.moveUp(); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + case 'l': + case 'right': + case 'return': + this.focus = 'diff'; + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + case 'g': + this.tree.selectFile(0); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + case 'G': + this.tree.selectFile(this.data.files.length - 1); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + default: + return; + } + this.render(); + } + + private handleDiffKey(key: string): void { + switch (key) { + case 'j': + case 'down': + this.diff.scrollDown(); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + case 'k': + case 'up': + this.diff.scrollUp(); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + case 'h': + case 'left': + this.focus = 'tree'; + break; + case 'space': + case 'pagedown': + this.diff.pageDown(); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + case 'b': + case 'pageup': + this.diff.pageUp(); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + case 'g': + this.diff.scrollToTop(); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + case 'G': + this.diff.scrollToBottom(); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + default: + return; + } + this.render(); + } + + private render(): void { + const borderCol = TREE_WIDTH; + const diffCol = TREE_WIDTH + BORDER_WIDTH; + const viewHeight = this.screen.rows; + + // Render tree panel + this.tree.render(this.screen, 0, 0, this.focus === 'tree'); + + // Render border + const borderStyle = applyFormatting( + this.context, + T().appendString('│').addSpan(0, 1, this.context.FILE_TREE_BORDER_COLOR) + ); + for (let r = 0; r < viewHeight; r++) { + this.screen.writeAt(r, borderCol, borderStyle + RESET, 1); + } + + // Render diff panel + this.diff.render(this.screen, diffCol, 0); + + this.screen.flush(); + } + + private quit(): void { + this.input.stop(); + this.screen.removeResizeListener(); + this.screen.exit(); + this.resolve(); + } +} diff --git a/src/tui/ansi.ts b/src/tui/ansi.ts new file mode 100644 index 0000000..55ce76f --- /dev/null +++ b/src/tui/ansi.ts @@ -0,0 +1,23 @@ +export const ESC = '\x1b'; + +// Alternate screen buffer +export const ENTER_ALT_SCREEN = `${ESC}[?1049h`; +export const EXIT_ALT_SCREEN = `${ESC}[?1049l`; + +// Cursor +export const HIDE_CURSOR = `${ESC}[?25l`; +export const SHOW_CURSOR = `${ESC}[?25h`; + +// Positioning +export function moveTo(row: number, col: number): string { + return `${ESC}[${row + 1};${col + 1}H`; +} + +// Clearing +export const CLEAR_SCREEN = `${ESC}[2J`; +export const CLEAR_LINE = `${ESC}[2K`; + +// Style +export const RESET = `${ESC}[0m`; +export const INVERSE = `${ESC}[7m`; +export const INVERSE_OFF = `${ESC}[27m`; diff --git a/src/tui/collectDiffData.ts b/src/tui/collectDiffData.ts new file mode 100644 index 0000000..9cff785 --- /dev/null +++ b/src/tui/collectDiffData.ts @@ -0,0 +1,65 @@ +import { Readable } from 'stream'; +import { Context } from '../context'; +import { applyFormatting } from '../formattedString'; +import { iterlinesFromReadable } from '../iterLinesFromReadable'; +import { iterReplaceTabsWithSpaces } from '../iterReplaceTabsWithSpaces'; +import { + DiffEvent, + iterSideBySideDiffsWithEvents, +} from '../iterSideBySideDiffs'; + +export interface DiffFile { + fileNameA: string; + fileNameB: string; + displayName: string; + startLineIndex: number; +} + +export interface DiffData { + files: DiffFile[]; + allRenderedLines: string[]; + fileBoundaries: number[]; +} + +function getDisplayName(fileNameA: string, fileNameB: string): string { + if (!fileNameA) return fileNameB || '(unknown)'; + if (!fileNameB) return fileNameA; + if (fileNameA === fileNameB) return fileNameA; + return `${fileNameA} → ${fileNameB}`; +} + +export async function collectDiffData( + context: Context, + input: Readable +): Promise { + const files: DiffFile[] = []; + const allRenderedLines: string[] = []; + const fileBoundaries: number[] = []; + + const lines = iterReplaceTabsWithSpaces( + context, + iterlinesFromReadable(input) + ); + + const events: AsyncIterable = iterSideBySideDiffsWithEvents( + context, + lines + ); + + for await (const event of events) { + if (event.type === 'file-start') { + const startLineIndex = allRenderedLines.length; + files.push({ + fileNameA: event.fileNameA, + fileNameB: event.fileNameB, + displayName: getDisplayName(event.fileNameA, event.fileNameB), + startLineIndex, + }); + fileBoundaries.push(startLineIndex); + } else { + allRenderedLines.push(applyFormatting(context, event.content)); + } + } + + return { files, allRenderedLines, fileBoundaries }; +} diff --git a/src/tui/sync.ts b/src/tui/sync.ts new file mode 100644 index 0000000..65f7b08 --- /dev/null +++ b/src/tui/sync.ts @@ -0,0 +1,56 @@ +import { FileTreePanel } from './FileTreePanel'; +import { DiffViewPanel } from './DiffViewPanel'; + +/** + * When the user selects a file in the tree, scroll the diff view to that file. + */ +export function syncTreeToDiff( + tree: FileTreePanel, + diff: DiffViewPanel, + fileBoundaries: number[] +): void { + const idx = tree.selectedIndex; + if (idx >= 0 && idx < fileBoundaries.length) { + diff.scrollToLine(fileBoundaries[idx]); + } +} + +/** + * When the user scrolls the diff view, update the tree to highlight + * the file visible at the top of the viewport. + */ +export function syncDiffToTree( + diff: DiffViewPanel, + tree: FileTreePanel, + fileBoundaries: number[] +): void { + if (fileBoundaries.length === 0) return; + + const topLine = diff.getTopVisibleLine(); + let fileIndex = binarySearchBoundary(fileBoundaries, topLine); + tree.selectFile(fileIndex); +} + +/** + * Binary search: find the last boundary <= topLine. + */ +function binarySearchBoundary( + boundaries: number[], + topLine: number +): number { + let lo = 0; + let hi = boundaries.length - 1; + let result = 0; + + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if (boundaries[mid] <= topLine) { + result = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return result; +} diff --git a/themes/arctic.json b/themes/arctic.json index 906aa6b..47f056e 100644 --- a/themes/arctic.json +++ b/themes/arctic.json @@ -62,5 +62,15 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#2E3440" + }, + "FILE_TREE_COLOR": { + "color": "#D8DEE9", + "backgroundColor": "#2E3440" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#EBCB8B" } } diff --git a/themes/dark.json b/themes/dark.json index f0b82ad..5c488ec 100644 --- a/themes/dark.json +++ b/themes/dark.json @@ -63,5 +63,16 @@ "backgroundColor": "#303a30" }, "UNMODIFIED_LINE_COLOR": {}, - "MISSING_LINE_COLOR": {} + "MISSING_LINE_COLOR": {}, + "FILE_TREE_COLOR": { + "color": "#cccccc", + "backgroundColor": "#1e1e1e" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#ffdd9966", + "modifiers": ["dim"] + } } diff --git a/themes/github-dark-dim.json b/themes/github-dark-dim.json index a5986d9..862c0b1 100644 --- a/themes/github-dark-dim.json +++ b/themes/github-dark-dim.json @@ -55,5 +55,15 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#2d333b" + }, + "FILE_TREE_COLOR": { + "color": "#adbac7", + "backgroundColor": "#1c2128" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#444c56" } } diff --git a/themes/github-light.json b/themes/github-light.json index b5efdbf..de4cb2c 100644 --- a/themes/github-light.json +++ b/themes/github-light.json @@ -66,5 +66,15 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#fafbfc" + }, + "FILE_TREE_COLOR": { + "color": "#24292e", + "backgroundColor": "#f6f8fa" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#e1e4e8" } } diff --git a/themes/light.json b/themes/light.json index 5ea8fcc..52fc1a6 100644 --- a/themes/light.json +++ b/themes/light.json @@ -60,5 +60,15 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#e8e8e8" + }, + "FILE_TREE_COLOR": { + "color": "#333333", + "backgroundColor": "#e8e8e8" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#cccccc" } } diff --git a/themes/monochrome-dark.json b/themes/monochrome-dark.json index 1042d45..fa6c8e5 100644 --- a/themes/monochrome-dark.json +++ b/themes/monochrome-dark.json @@ -55,5 +55,15 @@ "INSERTED_WORD_COLOR": { "color": "#222222", "backgroundColor": "#aaaaaa" + }, + "FILE_TREE_COLOR": { + "color": "#aaaaaa", + "backgroundColor": "#1a1a1a" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#666666" } } diff --git a/themes/monochrome-light.json b/themes/monochrome-light.json index 780f737..f45d757 100644 --- a/themes/monochrome-light.json +++ b/themes/monochrome-light.json @@ -53,5 +53,13 @@ "INSERTED_WORD_COLOR": { "color": "#dddddd", "backgroundColor": "#666666" - } + }, + "FILE_TREE_COLOR": { + "color": "#333333", + "backgroundColor": "#d0d0d0" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": {} } diff --git a/themes/solarized-dark.json b/themes/solarized-dark.json index 7c7958a..6856e51 100644 --- a/themes/solarized-dark.json +++ b/themes/solarized-dark.json @@ -53,5 +53,15 @@ "backgroundColor": "#003b36" }, "UNMODIFIED_LINE_COLOR": {}, - "MISSING_LINE_COLOR": {} + "MISSING_LINE_COLOR": {}, + "FILE_TREE_COLOR": { + "color": "#839496", + "backgroundColor": "#073642" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#93a1a1" + } } diff --git a/themes/solarized-light.json b/themes/solarized-light.json index b1a3a62..dd8f69e 100644 --- a/themes/solarized-light.json +++ b/themes/solarized-light.json @@ -55,5 +55,15 @@ "backgroundColor": "#edf6d3" }, "UNMODIFIED_LINE_COLOR": {}, - "MISSING_LINE_COLOR": {} + "MISSING_LINE_COLOR": {}, + "FILE_TREE_COLOR": { + "color": "#657b83", + "backgroundColor": "#eee8d5" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": ["inverse"] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#586e75" + } } From 4d11f97742b9732f48b75c0d4c13ee95cc1e762f Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:07:19 +0000 Subject: [PATCH 02/18] Add per-file addition/deletion line counts Co-Authored-By: Claude Opus 4.6 --- src/iterSideBySideDiffs.ts | 12 +++++++++++- src/tui/collectDiffData.ts | 4 ++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/iterSideBySideDiffs.ts b/src/iterSideBySideDiffs.ts index 477d1b3..89dbd24 100644 --- a/src/iterSideBySideDiffs.ts +++ b/src/iterSideBySideDiffs.ts @@ -325,7 +325,7 @@ export async function* iterSideBySideDiffs( export type DiffEvent = | { type: 'line'; content: FormattedString } - | { type: 'file-start'; fileNameA: string; fileNameB: string }; + | { type: 'file-start'; fileNameA: string; fileNameB: string; additions: number; deletions: number }; export async function* iterSideBySideDiffsWithEvents( context: Context, @@ -337,6 +337,8 @@ export async function* iterSideBySideDiffsWithEvents( let isFirstCommitBodyLine = false; let fileNameA: string = ''; let fileNameB: string = ''; + let fileAdditions: number = 0; + let fileDeletions: number = 0; let hunkParts: HunkPart[] = []; let hunkHeaderLine: string = ''; @@ -360,6 +362,8 @@ export async function* iterSideBySideDiffsWithEvents( type: 'file-start', fileNameA, fileNameB, + additions: fileAdditions, + deletions: fileDeletions, }; for (const line of yieldFileNameLines()) { yield { type: 'line', content: line }; @@ -424,6 +428,8 @@ export async function* iterSideBySideDiffsWithEvents( case 'unified-diff': fileNameA = ''; fileNameB = ''; + fileAdditions = 0; + fileDeletions = 0; break; case 'unified-diff-hunk-header': hunkParts = [ @@ -513,8 +519,10 @@ export async function* iterSideBySideDiffsWithEvents( hunkParts; if (line.startsWith('-')) { hunkLinesA.push(line); + fileDeletions++; } else if (line.startsWith('+')) { hunkLinesB.push(line); + fileAdditions++; } else { while (hunkLinesA.length < hunkLinesB.length) { hunkLinesA.push(null); @@ -579,8 +587,10 @@ export async function* iterSideBySideDiffsWithEvents( } if (isLineRemoved) { hunkParts[i].lines.push('-' + lineSuffix); + fileDeletions++; } else if (isLineAdded) { hunkParts[i].lines.push('+' + lineSuffix); + fileAdditions++; } else { hunkParts[i].lines.push(' ' + lineSuffix); } diff --git a/src/tui/collectDiffData.ts b/src/tui/collectDiffData.ts index 9cff785..3e7db29 100644 --- a/src/tui/collectDiffData.ts +++ b/src/tui/collectDiffData.ts @@ -13,6 +13,8 @@ export interface DiffFile { fileNameB: string; displayName: string; startLineIndex: number; + additions: number; + deletions: number; } export interface DiffData { @@ -54,6 +56,8 @@ export async function collectDiffData( fileNameB: event.fileNameB, displayName: getDisplayName(event.fileNameA, event.fileNameB), startLineIndex, + additions: event.additions, + deletions: event.deletions, }); fileBoundaries.push(startLineIndex); } else { From c434705797ef457266c96b9a9f92419cab3094ad Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:07:24 +0000 Subject: [PATCH 03/18] Add dir, border-focused, additions, deletions theme colors Co-Authored-By: Claude Opus 4.6 --- src/themes.ts | 12 ++++++++++++ themes/arctic.json | 16 +++++++++++++++- themes/dark.json | 32 +++++++++++++++++++++++++++----- themes/github-dark-dim.json | 20 ++++++++++++++++++-- themes/github-light.json | 20 ++++++++++++++++++-- themes/light.json | 16 +++++++++++++++- themes/monochrome-dark.json | 24 +++++++++++++++++++++--- themes/monochrome-light.json | 26 ++++++++++++++++++++++---- themes/solarized-dark.json | 16 +++++++++++++++- themes/solarized-light.json | 16 +++++++++++++++- 10 files changed, 178 insertions(+), 20 deletions(-) diff --git a/src/themes.ts b/src/themes.ts index 2ba9120..3046909 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -49,12 +49,20 @@ export enum ThemeColorName { FILE_TREE_COLOR = 'FILE_TREE_COLOR', FILE_TREE_SELECTED_COLOR = 'FILE_TREE_SELECTED_COLOR', FILE_TREE_BORDER_COLOR = 'FILE_TREE_BORDER_COLOR', + FILE_TREE_DIR_COLOR = 'FILE_TREE_DIR_COLOR', + FILE_TREE_BORDER_FOCUSED_COLOR = 'FILE_TREE_BORDER_FOCUSED_COLOR', + FILE_TREE_ADDITIONS_COLOR = 'FILE_TREE_ADDITIONS_COLOR', + FILE_TREE_DELETIONS_COLOR = 'FILE_TREE_DELETIONS_COLOR', } const OPTIONAL_THEME_COLORS: Set = new Set([ ThemeColorName.FILE_TREE_COLOR, ThemeColorName.FILE_TREE_SELECTED_COLOR, ThemeColorName.FILE_TREE_BORDER_COLOR, + ThemeColorName.FILE_TREE_DIR_COLOR, + ThemeColorName.FILE_TREE_BORDER_FOCUSED_COLOR, + ThemeColorName.FILE_TREE_ADDITIONS_COLOR, + ThemeColorName.FILE_TREE_DELETIONS_COLOR, ]); const OPTIONAL_THEME_COLOR_DEFAULTS: Partial< @@ -66,6 +74,10 @@ const OPTIONAL_THEME_COLOR_DEFAULTS: Partial< color: '#ffdd9966', modifiers: ['dim'], }, + [ThemeColorName.FILE_TREE_DIR_COLOR]: { color: '#66cccc' }, + [ThemeColorName.FILE_TREE_BORDER_FOCUSED_COLOR]: { color: '#ffdd99' }, + [ThemeColorName.FILE_TREE_ADDITIONS_COLOR]: { color: '#66cc66' }, + [ThemeColorName.FILE_TREE_DELETIONS_COLOR]: { color: '#cc6666' }, }; export type ThemeDefinition = { diff --git a/themes/arctic.json b/themes/arctic.json index 47f056e..865fe99 100644 --- a/themes/arctic.json +++ b/themes/arctic.json @@ -68,9 +68,23 @@ "backgroundColor": "#2E3440" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#EBCB8B" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#88C0D0" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#EBCB8B" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#A3BE8C" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#BF616A" } } diff --git a/themes/dark.json b/themes/dark.json index 5c488ec..9c991f6 100644 --- a/themes/dark.json +++ b/themes/dark.json @@ -23,17 +23,23 @@ "color": "#cccccc" }, "COMMIT_TITLE_COLOR": { - "modifiers": ["bold"] + "modifiers": [ + "bold" + ] }, "FILE_NAME_COLOR": { "color": "#ffdd99" }, "BORDER_COLOR": { "color": "#ffdd9966", - "modifiers": ["dim"] + "modifiers": [ + "dim" + ] }, "HUNK_HEADER_COLOR": { - "modifiers": ["dim"] + "modifiers": [ + "dim" + ] }, "DELETED_WORD_COLOR": { "color": "#ffcccc", @@ -69,10 +75,26 @@ "backgroundColor": "#1e1e1e" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#ffdd9966", - "modifiers": ["dim"] + "modifiers": [ + "dim" + ] + }, + "FILE_TREE_DIR_COLOR": { + "color": "#66cccc" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#ffdd99" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#66cc66" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#cc6666" } } diff --git a/themes/github-dark-dim.json b/themes/github-dark-dim.json index 862c0b1..4e05bfb 100644 --- a/themes/github-dark-dim.json +++ b/themes/github-dark-dim.json @@ -18,7 +18,9 @@ }, "COMMIT_TITLE_COLOR": { "color": "#c6e6ff", - "modifiers": ["bold"] + "modifiers": [ + "bold" + ] }, "BORDER_COLOR": { "color": "#444c56", @@ -61,9 +63,23 @@ "backgroundColor": "#1c2128" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#444c56" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#539bf5" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#539bf5" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#57ab5a" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#e5534b" } } diff --git a/themes/github-light.json b/themes/github-light.json index de4cb2c..be139b2 100644 --- a/themes/github-light.json +++ b/themes/github-light.json @@ -26,7 +26,9 @@ }, "COMMIT_TITLE_COLOR": { "color": "#05264c", - "modifiers": ["bold"] + "modifiers": [ + "bold" + ] }, "BORDER_COLOR": { "color": "#e1e4e8" @@ -72,9 +74,23 @@ "backgroundColor": "#f6f8fa" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#e1e4e8" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#0366d6" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#0366d6" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#28a745" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#d73a49" } } diff --git a/themes/light.json b/themes/light.json index 52fc1a6..3f45bf4 100644 --- a/themes/light.json +++ b/themes/light.json @@ -66,9 +66,23 @@ "backgroundColor": "#e8e8e8" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#cccccc" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#2299aa" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#336666" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#228822" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#aa3333" } } diff --git a/themes/monochrome-dark.json b/themes/monochrome-dark.json index fa6c8e5..5491197 100644 --- a/themes/monochrome-dark.json +++ b/themes/monochrome-dark.json @@ -15,7 +15,9 @@ "color": "#eeeeee" }, "COMMIT_TITLE_COLOR": { - "modifiers": ["bold"] + "modifiers": [ + "bold" + ] }, "FILE_NAME_COLOR": { "color": "#ffffff" @@ -24,7 +26,9 @@ "color": "#666666" }, "HUNK_HEADER_COLOR": { - "modifiers": ["dim"], + "modifiers": [ + "dim" + ], "color": "#ffffff" }, "DELETED_LINE_NO_COLOR": { @@ -61,9 +65,23 @@ "backgroundColor": "#1a1a1a" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#666666" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#cccccc" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#ffffff" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#cccccc" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#888888" } } diff --git a/themes/monochrome-light.json b/themes/monochrome-light.json index f45d757..2974808 100644 --- a/themes/monochrome-light.json +++ b/themes/monochrome-light.json @@ -15,14 +15,18 @@ "color": "#111111" }, "COMMIT_TITLE_COLOR": { - "modifiers": ["bold"] + "modifiers": [ + "bold" + ] }, "FILE_NAME_COLOR": { "color": "#000000" }, "BORDER_COLOR": {}, "HUNK_HEADER_COLOR": { - "modifiers": ["dim"], + "modifiers": [ + "dim" + ], "color": "#000000" }, "DELETED_LINE_NO_COLOR": { @@ -59,7 +63,21 @@ "backgroundColor": "#d0d0d0" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, - "FILE_TREE_BORDER_COLOR": {} + "FILE_TREE_BORDER_COLOR": {}, + "FILE_TREE_DIR_COLOR": { + "color": "#333333" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#000000" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#333333" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#888888" + } } diff --git a/themes/solarized-dark.json b/themes/solarized-dark.json index 6856e51..97c82e3 100644 --- a/themes/solarized-dark.json +++ b/themes/solarized-dark.json @@ -59,9 +59,23 @@ "backgroundColor": "#073642" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#93a1a1" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#268bd2" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#b58900" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#859900" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#dc322f" } } diff --git a/themes/solarized-light.json b/themes/solarized-light.json index dd8f69e..ba6e107 100644 --- a/themes/solarized-light.json +++ b/themes/solarized-light.json @@ -61,9 +61,23 @@ "backgroundColor": "#eee8d5" }, "FILE_TREE_SELECTED_COLOR": { - "modifiers": ["inverse"] + "modifiers": [ + "inverse" + ] }, "FILE_TREE_BORDER_COLOR": { "color": "#586e75" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#268bd2" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#b58900" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#859900" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#dc322f" } } From 0575e48eb4c7defed3c26430c5735ea98f728a99 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:07:28 +0000 Subject: [PATCH 04/18] Rewrite file tree as nested expandable tree with file icons and +N -M stats Co-Authored-By: Claude Opus 4.6 --- src/tui/FileTreePanel.ts | 356 ++++++++++++++++++++++++++++++++++----- src/tui/fileIcons.ts | 69 ++++++++ 2 files changed, 386 insertions(+), 39 deletions(-) create mode 100644 src/tui/fileIcons.ts diff --git a/src/tui/FileTreePanel.ts b/src/tui/FileTreePanel.ts index cdb7e6b..5cac2e1 100644 --- a/src/tui/FileTreePanel.ts +++ b/src/tui/FileTreePanel.ts @@ -1,11 +1,101 @@ +import * as path from 'path'; import { Context } from '../context'; import { applyFormatting, T } from '../formattedString'; import { Screen } from './Screen'; import { DiffFile } from './collectDiffData'; import { RESET } from './ansi'; +import { getFileIcon, FOLDER_CLOSED, FOLDER_OPEN } from './fileIcons'; + +type DirNode = { + type: 'dir'; + name: string; + path: string; + children: TreeNode[]; + expanded: boolean; + depth: number; +}; + +type FileNode = { + type: 'file'; + name: string; + path: string; + file: DiffFile; + fileIndex: number; + depth: number; +}; + +type TreeNode = DirNode | FileNode; + +type VisibleNode = + | { type: 'dir'; node: DirNode; depth: number } + | { type: 'file'; node: FileNode; fileIndex: number; depth: number }; + +function buildTree(files: DiffFile[]): TreeNode[] { + const root: TreeNode[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const filePath = file.fileNameB || file.fileNameA || file.displayName; + const parts = filePath.split('/'); + let children = root; + let currentPath = ''; + + for (let j = 0; j < parts.length - 1; j++) { + currentPath = currentPath ? currentPath + '/' + parts[j] : parts[j]; + let dir = children.find( + (n) => n.type === 'dir' && n.name === parts[j] + ) as DirNode | undefined; + if (!dir) { + dir = { + type: 'dir', + name: parts[j], + path: currentPath, + children: [], + expanded: true, + depth: j, + }; + children.push(dir); + } + children = dir.children; + } + + children.push({ + type: 'file', + name: parts[parts.length - 1], + path: filePath, + file, + fileIndex: i, + depth: parts.length - 1, + }); + } + + return root; +} + +function flattenVisible(nodes: TreeNode[], depth: number = 0): VisibleNode[] { + const result: VisibleNode[] = []; + for (const node of nodes) { + if (node.type === 'dir') { + result.push({ type: 'dir', node, depth }); + if (node.expanded) { + result.push(...flattenVisible(node.children, depth + 1)); + } + } else { + result.push({ + type: 'file', + node, + fileIndex: node.fileIndex, + depth, + }); + } + } + return result; +} export class FileTreePanel { private files: DiffFile[]; + private rootNodes: TreeNode[]; + private visibleNodes: VisibleNode[] = []; private width: number; private height: number; private context: Context; @@ -22,6 +112,16 @@ export class FileTreePanel { this.width = width; this.height = height; this.context = context; + this.rootNodes = buildTree(files); + this.regenerateVisible(); + } + + get viewHeight(): number { + return this.height; + } + + private regenerateVisible(): void { + this.visibleNodes = flattenVisible(this.rootNodes); } resize(width: number, height: number): void { @@ -38,21 +138,131 @@ export class FileTreePanel { } moveDown(): void { - if (this.selectedIndex < this.files.length - 1) { + if (this.selectedIndex < this.visibleNodes.length - 1) { this.selectedIndex++; this.ensureVisible(); } } - selectFile(index: number): void { - if (index >= 0 && index < this.files.length) { - this.selectedIndex = index; + moveBy(delta: number): void { + const newIndex = Math.max( + 0, + Math.min(this.visibleNodes.length - 1, this.selectedIndex + delta) + ); + this.selectedIndex = newIndex; + this.ensureVisible(); + } + + /** + * Toggle expand/collapse on a dir, or focus diff on a file. + * Returns true if a file was selected (caller should switch focus). + */ + toggleOrSelect(): boolean { + const vn = this.visibleNodes[this.selectedIndex]; + if (!vn) return false; + if (vn.type === 'dir') { + vn.node.expanded = !vn.node.expanded; + this.regenerateVisible(); + // Clamp selection if collapsed nodes removed entries below + if (this.selectedIndex >= this.visibleNodes.length) { + this.selectedIndex = this.visibleNodes.length - 1; + } this.ensureVisible(); + return false; } + return true; // file selected } - getSelectedFile(): DiffFile | undefined { - return this.files[this.selectedIndex]; + /** + * Collapse current dir, or navigate to parent dir. + */ + collapseOrParent(): void { + const vn = this.visibleNodes[this.selectedIndex]; + if (!vn) return; + + if (vn.type === 'dir' && vn.node.expanded) { + vn.node.expanded = false; + this.regenerateVisible(); + if (this.selectedIndex >= this.visibleNodes.length) { + this.selectedIndex = this.visibleNodes.length - 1; + } + this.ensureVisible(); + return; + } + + // Navigate to parent directory + const nodePath = + vn.type === 'dir' ? vn.node.path : vn.node.path; + const parentPath = path.dirname(nodePath); + if (parentPath === '.' || parentPath === nodePath) return; + + for (let i = this.selectedIndex - 1; i >= 0; i--) { + const candidate = this.visibleNodes[i]; + if (candidate.type === 'dir' && candidate.node.path === parentPath) { + this.selectedIndex = i; + this.ensureVisible(); + return; + } + } + } + + /** + * Returns the original file index if a file is selected, undefined for dirs. + */ + getSelectedFileIndex(): number | undefined { + const vn = this.visibleNodes[this.selectedIndex]; + if (!vn || vn.type !== 'file') return undefined; + return vn.fileIndex; + } + + /** + * Select a file by its original index in the files array. + * Expands ancestors if collapsed. + */ + selectFileByIndex(idx: number): void { + if (idx < 0 || idx >= this.files.length) return; + const file = this.files[idx]; + const filePath = file.fileNameB || file.fileNameA || file.displayName; + + // Ensure all ancestor dirs are expanded + this.expandAncestors(filePath); + this.regenerateVisible(); + + // Find the visible node with this file index + for (let i = 0; i < this.visibleNodes.length; i++) { + const vn = this.visibleNodes[i]; + if (vn.type === 'file' && vn.fileIndex === idx) { + this.selectedIndex = i; + this.ensureVisible(); + return; + } + } + } + + selectFirst(): void { + this.selectedIndex = 0; + this.ensureVisible(); + } + + selectLast(): void { + this.selectedIndex = Math.max(0, this.visibleNodes.length - 1); + this.ensureVisible(); + } + + private expandAncestors(filePath: string): void { + const parts = filePath.split('/'); + let children = this.rootNodes; + for (let i = 0; i < parts.length - 1; i++) { + const dir = children.find( + (n) => n.type === 'dir' && n.name === parts[i] + ) as DirNode | undefined; + if (dir) { + dir.expanded = true; + children = dir.children; + } else { + break; + } + } } private ensureVisible(): void { @@ -64,20 +274,30 @@ export class FileTreePanel { } private clampScroll(): void { - const maxScroll = Math.max(0, this.files.length - this.height); + const maxScroll = Math.max(0, this.visibleNodes.length - this.height); this.scrollOffset = Math.min(this.scrollOffset, maxScroll); this.scrollOffset = Math.max(0, this.scrollOffset); } - render(screen: Screen, startCol: number, startRow: number, focused: boolean): void { - const { FILE_TREE_COLOR, FILE_TREE_SELECTED_COLOR } = this.context; + render( + screen: Screen, + startCol: number, + startRow: number, + focused: boolean + ): void { + const { + FILE_TREE_COLOR, + FILE_TREE_SELECTED_COLOR, + FILE_TREE_DIR_COLOR, + FILE_TREE_ADDITIONS_COLOR, + FILE_TREE_DELETIONS_COLOR, + } = this.context; for (let row = 0; row < this.height; row++) { - const fileIndex = this.scrollOffset + row; + const visIdx = this.scrollOffset + row; const screenRow = startRow + row; - if (fileIndex >= this.files.length) { - // Empty row — fill with tree background + if (visIdx >= this.visibleNodes.length) { const emptyLine = T() .fillWidth(this.width, ' ') .addSpan(0, this.width, FILE_TREE_COLOR); @@ -90,38 +310,96 @@ export class FileTreePanel { continue; } - const file = this.files[fileIndex]; - const isSelected = fileIndex === this.selectedIndex; + const vn = this.visibleNodes[visIdx]; + const isSelected = visIdx === this.selectedIndex; + const indent = ' '.repeat(vn.depth); - const statusChar = this.getStatusChar(file); - const label = ` ${statusChar} ${file.displayName} `; + if (vn.type === 'dir') { + const icon = vn.node.expanded ? FOLDER_OPEN : FOLDER_CLOSED; + const label = ` ${indent}${icon} ${vn.node.name}`; - const line = T() - .appendString(label) - .fillWidth(this.width, ' ') - .addSpan(0, this.width, FILE_TREE_COLOR); + const line = T() + .appendString(label) + .fillWidth(this.width, ' ') + .addSpan(0, this.width, FILE_TREE_DIR_COLOR); - if (isSelected) { - line.addSpan(0, this.width, FILE_TREE_SELECTED_COLOR); - if (focused) { - // Add a marker for focused+selected - // The inverse modifier already handles highlight + if (isSelected) { + line.addSpan(0, this.width, FILE_TREE_SELECTED_COLOR); } - } - screen.writeAt( - screenRow, - startCol, - applyFormatting(this.context, line) + RESET, - this.width - ); - } - } + screen.writeAt( + screenRow, + startCol, + applyFormatting(this.context, line) + RESET, + this.width + ); + } else { + const icon = getFileIcon(vn.node.path); + const adds = vn.node.file.additions; + const dels = vn.node.file.deletions; - private getStatusChar(file: DiffFile): string { - if (!file.fileNameA) return 'A'; - if (!file.fileNameB) return 'D'; - if (file.fileNameA !== file.fileNameB) return 'R'; - return 'M'; + // Build the stat suffix + let stat = ''; + if (adds > 0) stat += `+${adds}`; + if (dels > 0) stat += (stat ? ' ' : '') + `-${dels}`; + + const prefix = ` ${indent}${icon} `; + const suffix = stat ? ` ${stat} ` : ' '; + const maxNameLen = this.width - prefix.length - suffix.length; + let name = vn.node.name; + if (name.length > maxNameLen && maxNameLen > 3) { + name = name.slice(0, maxNameLen - 1) + '…'; + } else if (maxNameLen <= 3) { + name = name.slice(0, Math.max(1, maxNameLen)); + } + + const leftPart = prefix + name; + const padding = Math.max( + 0, + this.width - leftPart.length - suffix.length + ); + const fullText = leftPart + ' '.repeat(padding) + suffix; + + const line = T() + .appendString(fullText) + .fillWidth(this.width, ' ') + .addSpan(0, this.width, FILE_TREE_COLOR); + + // Color the stat portion + if (stat) { + const statStart = this.width - suffix.length + 1; + // Color additions part + if (adds > 0) { + const addStr = `+${adds}`; + const addStart = statStart; + line.addSpan( + addStart, + addStart + addStr.length, + FILE_TREE_ADDITIONS_COLOR + ); + } + if (dels > 0) { + const delStr = `-${dels}`; + const delStart = this.width - 1 - delStr.length; + line.addSpan( + delStart, + delStart + delStr.length, + FILE_TREE_DELETIONS_COLOR + ); + } + } + + if (isSelected) { + line.addSpan(0, this.width, FILE_TREE_SELECTED_COLOR); + } + + screen.writeAt( + screenRow, + startCol, + applyFormatting(this.context, line) + RESET, + this.width + ); + } + } } } diff --git a/src/tui/fileIcons.ts b/src/tui/fileIcons.ts new file mode 100644 index 0000000..44c6811 --- /dev/null +++ b/src/tui/fileIcons.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; + +const ICON_MAP: Record = { + '.ts': '\ue628', + '.tsx': '\ue7ba', + '.js': '\ue74e', + '.jsx': '\ue7ba', + '.json': '\ue60b', + '.md': '\ue73e', + '.swift': '\ue755', + '.py': '\ue73c', + '.rb': '\ue791', + '.rs': '\ue7a8', + '.go': '\ue627', + '.java': '\ue738', + '.css': '\ue749', + '.scss': '\ue749', + '.less': '\ue749', + '.html': '\ue736', + '.yml': '\ue6a8', + '.yaml': '\ue6a8', + '.sh': '\ue795', + '.bash': '\ue795', + '.zsh': '\ue795', + '.graphql': '\ue662', + '.gql': '\ue662', + '.vue': '\ue6a0', + '.svelte': '\ue697', + '.c': '\ue61e', + '.cpp': '\ue61d', + '.h': '\ue61e', + '.hpp': '\ue61d', + '.cs': '\uf81a', + '.php': '\ue73d', + '.lua': '\ue620', + '.toml': '\ue6b2', + '.lock': '\uf023', + '.sql': '\ue706', + '.dockerfile': '\ue7b0', + '.docker': '\ue7b0', + '.xml': '\ue619', + '.svg': '\ue698', + '.png': '\uf1c5', + '.jpg': '\uf1c5', + '.gif': '\uf1c5', + '.ico': '\uf1c5', + '.txt': '\uf15c', + '.env': '\uf462', + '.gitignore': '\ue702', +}; + +const DEFAULT_FILE_ICON = '\uf15b'; +export const FOLDER_CLOSED = '\uf07b'; +export const FOLDER_OPEN = '\uf07c'; + +export function getFileIcon(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + if (ext && ICON_MAP[ext]) { + return ICON_MAP[ext]; + } + const base = path.basename(filePath).toLowerCase(); + if (base === 'dockerfile' || base.startsWith('dockerfile.')) { + return ICON_MAP['.dockerfile']; + } + if (base === '.gitignore') { + return ICON_MAP['.gitignore']; + } + return DEFAULT_FILE_ICON; +} From 92ae3242840457993caa817f2ae85e1a0881acc4 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:07:32 +0000 Subject: [PATCH 05/18] Add focus border indicator, ctrl+d/u half-page scroll, update sync for nested tree Co-Authored-By: Claude Opus 4.6 --- src/tui/DiffViewPanel.ts | 4 ++ src/tui/TuiApp.ts | 94 +++++++++++++++++++++++++++++++++++----- src/tui/sync.ts | 9 ++-- 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/src/tui/DiffViewPanel.ts b/src/tui/DiffViewPanel.ts index 5e1d3fb..6f70fc0 100644 --- a/src/tui/DiffViewPanel.ts +++ b/src/tui/DiffViewPanel.ts @@ -13,6 +13,10 @@ export class DiffViewPanel { this.height = height; } + get viewHeight(): number { + return this.height; + } + resize(width: number, height: number): void { this.width = width; this.height = height; diff --git a/src/tui/TuiApp.ts b/src/tui/TuiApp.ts index 1de0f14..ca0d7e8 100644 --- a/src/tui/TuiApp.ts +++ b/src/tui/TuiApp.ts @@ -101,13 +101,42 @@ export class TuiApp { } if (this.focus === 'tree') { - this.handleTreeKey(key); + this.handleTreeKey(key, ctrl); } else { - this.handleDiffKey(key); + this.handleDiffKey(key, ctrl); } } - private handleTreeKey(key: string): void { + private handleTreeKey(key: string, ctrl: boolean): void { + if (ctrl) { + switch (key) { + case 'd': { + const half = Math.floor(this.tree.viewHeight / 2); + this.tree.moveBy(half); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + } + case 'u': { + const half = Math.floor(this.tree.viewHeight / 2); + this.tree.moveBy(-half); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + } + default: + return; + } + this.render(); + return; + } + switch (key) { case 'j': case 'down': @@ -129,8 +158,21 @@ export class TuiApp { break; case 'l': case 'right': - case 'return': - this.focus = 'diff'; + case 'return': { + const isFile = this.tree.toggleOrSelect(); + if (isFile) { + this.focus = 'diff'; + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + } + break; + } + case 'h': + case 'left': + this.tree.collapseOrParent(); syncTreeToDiff( this.tree, this.diff, @@ -138,7 +180,7 @@ export class TuiApp { ); break; case 'g': - this.tree.selectFile(0); + this.tree.selectFirst(); syncTreeToDiff( this.tree, this.diff, @@ -146,7 +188,7 @@ export class TuiApp { ); break; case 'G': - this.tree.selectFile(this.data.files.length - 1); + this.tree.selectLast(); syncTreeToDiff( this.tree, this.diff, @@ -159,7 +201,36 @@ export class TuiApp { this.render(); } - private handleDiffKey(key: string): void { + private handleDiffKey(key: string, ctrl: boolean): void { + if (ctrl) { + switch (key) { + case 'd': { + const half = Math.floor(this.diff.viewHeight / 2); + this.diff.scrollDown(half); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + } + case 'u': { + const half = Math.floor(this.diff.viewHeight / 2); + this.diff.scrollUp(half); + syncDiffToTree( + this.diff, + this.tree, + this.data.fileBoundaries + ); + break; + } + default: + return; + } + this.render(); + return; + } + switch (key) { case 'j': case 'down': @@ -231,10 +302,13 @@ export class TuiApp { // Render tree panel this.tree.render(this.screen, 0, 0, this.focus === 'tree'); - // Render border + // Render border — use focused color when tree is focused + const borderColor = this.focus === 'tree' + ? this.context.FILE_TREE_BORDER_FOCUSED_COLOR + : this.context.FILE_TREE_BORDER_COLOR; const borderStyle = applyFormatting( this.context, - T().appendString('│').addSpan(0, 1, this.context.FILE_TREE_BORDER_COLOR) + T().appendString('│').addSpan(0, 1, borderColor) ); for (let r = 0; r < viewHeight; r++) { this.screen.writeAt(r, borderCol, borderStyle + RESET, 1); diff --git a/src/tui/sync.ts b/src/tui/sync.ts index 65f7b08..451a95c 100644 --- a/src/tui/sync.ts +++ b/src/tui/sync.ts @@ -3,16 +3,19 @@ import { DiffViewPanel } from './DiffViewPanel'; /** * When the user selects a file in the tree, scroll the diff view to that file. + * Returns false if the selected node is a dir (no sync needed). */ export function syncTreeToDiff( tree: FileTreePanel, diff: DiffViewPanel, fileBoundaries: number[] -): void { - const idx = tree.selectedIndex; +): boolean { + const idx = tree.getSelectedFileIndex(); + if (idx === undefined) return false; if (idx >= 0 && idx < fileBoundaries.length) { diff.scrollToLine(fileBoundaries[idx]); } + return true; } /** @@ -28,7 +31,7 @@ export function syncDiffToTree( const topLine = diff.getTopVisibleLine(); let fileIndex = binarySearchBoundary(fileBoundaries, topLine); - tree.selectFile(fileIndex); + tree.selectFileByIndex(fileIndex); } /** From 53d1685e2e2cb59f68b649f4de269ed5d3554fd0 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:18:46 +0000 Subject: [PATCH 06/18] Match folder color to file name header, darken tree background Co-Authored-By: Claude Opus 4.6 --- src/themes.ts | 6 ++++++ themes/arctic.json | 10 ++++++++-- themes/dark.json | 10 ++++++++-- themes/github-dark-dim.json | 10 ++++++++-- themes/github-light.json | 10 ++++++++-- themes/light.json | 10 ++++++++-- themes/monochrome-dark.json | 8 +++++++- themes/monochrome-light.json | 10 ++++++++-- themes/solarized-dark.json | 10 ++++++++-- themes/solarized-light.json | 10 ++++++++-- 10 files changed, 77 insertions(+), 17 deletions(-) diff --git a/src/themes.ts b/src/themes.ts index 3046909..e45050c 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -53,6 +53,8 @@ export enum ThemeColorName { FILE_TREE_BORDER_FOCUSED_COLOR = 'FILE_TREE_BORDER_FOCUSED_COLOR', FILE_TREE_ADDITIONS_COLOR = 'FILE_TREE_ADDITIONS_COLOR', FILE_TREE_DELETIONS_COLOR = 'FILE_TREE_DELETIONS_COLOR', + FILE_TREE_STAGED_COLOR = 'FILE_TREE_STAGED_COLOR', + FILE_TREE_PARTIAL_STAGED_COLOR = 'FILE_TREE_PARTIAL_STAGED_COLOR', } const OPTIONAL_THEME_COLORS: Set = new Set([ @@ -63,6 +65,8 @@ const OPTIONAL_THEME_COLORS: Set = new Set([ ThemeColorName.FILE_TREE_BORDER_FOCUSED_COLOR, ThemeColorName.FILE_TREE_ADDITIONS_COLOR, ThemeColorName.FILE_TREE_DELETIONS_COLOR, + ThemeColorName.FILE_TREE_STAGED_COLOR, + ThemeColorName.FILE_TREE_PARTIAL_STAGED_COLOR, ]); const OPTIONAL_THEME_COLOR_DEFAULTS: Partial< @@ -78,6 +82,8 @@ const OPTIONAL_THEME_COLOR_DEFAULTS: Partial< [ThemeColorName.FILE_TREE_BORDER_FOCUSED_COLOR]: { color: '#ffdd99' }, [ThemeColorName.FILE_TREE_ADDITIONS_COLOR]: { color: '#66cc66' }, [ThemeColorName.FILE_TREE_DELETIONS_COLOR]: { color: '#cc6666' }, + [ThemeColorName.FILE_TREE_STAGED_COLOR]: { color: '#66cc66' }, + [ThemeColorName.FILE_TREE_PARTIAL_STAGED_COLOR]: { color: '#cc9944' }, }; export type ThemeDefinition = { diff --git a/themes/arctic.json b/themes/arctic.json index 865fe99..74fc81b 100644 --- a/themes/arctic.json +++ b/themes/arctic.json @@ -65,7 +65,7 @@ }, "FILE_TREE_COLOR": { "color": "#D8DEE9", - "backgroundColor": "#2E3440" + "backgroundColor": "#232323" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -76,7 +76,7 @@ "color": "#EBCB8B" }, "FILE_TREE_DIR_COLOR": { - "color": "#88C0D0" + "color": "#EBCB8B" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#EBCB8B" @@ -86,5 +86,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#BF616A" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#A3BE8C" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#D08770" } } diff --git a/themes/dark.json b/themes/dark.json index 9c991f6..e9e538f 100644 --- a/themes/dark.json +++ b/themes/dark.json @@ -72,7 +72,7 @@ "MISSING_LINE_COLOR": {}, "FILE_TREE_COLOR": { "color": "#cccccc", - "backgroundColor": "#1e1e1e" + "backgroundColor": "#232323" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -86,7 +86,7 @@ ] }, "FILE_TREE_DIR_COLOR": { - "color": "#66cccc" + "color": "#ffdd99" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#ffdd99" @@ -96,5 +96,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#cc6666" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#66cc66" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#cc9944" } } diff --git a/themes/github-dark-dim.json b/themes/github-dark-dim.json index 4e05bfb..c341634 100644 --- a/themes/github-dark-dim.json +++ b/themes/github-dark-dim.json @@ -60,7 +60,7 @@ }, "FILE_TREE_COLOR": { "color": "#adbac7", - "backgroundColor": "#1c2128" + "backgroundColor": "#1a2025" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -71,7 +71,7 @@ "color": "#444c56" }, "FILE_TREE_DIR_COLOR": { - "color": "#539bf5" + "color": "#adbac7" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#539bf5" @@ -81,5 +81,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#e5534b" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#57ab5a" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#c69026" } } diff --git a/themes/github-light.json b/themes/github-light.json index be139b2..52d1b77 100644 --- a/themes/github-light.json +++ b/themes/github-light.json @@ -71,7 +71,7 @@ }, "FILE_TREE_COLOR": { "color": "#24292e", - "backgroundColor": "#f6f8fa" + "backgroundColor": "#f7f7f7" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -82,7 +82,7 @@ "color": "#e1e4e8" }, "FILE_TREE_DIR_COLOR": { - "color": "#0366d6" + "color": "#05264c" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#0366d6" @@ -92,5 +92,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#d73a49" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#28a745" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#d15704" } } diff --git a/themes/light.json b/themes/light.json index 3f45bf4..bec153a 100644 --- a/themes/light.json +++ b/themes/light.json @@ -63,7 +63,7 @@ }, "FILE_TREE_COLOR": { "color": "#333333", - "backgroundColor": "#e8e8e8" + "backgroundColor": "#e6e6e6" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -74,7 +74,7 @@ "color": "#cccccc" }, "FILE_TREE_DIR_COLOR": { - "color": "#2299aa" + "color": "#336666" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#336666" @@ -84,5 +84,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#aa3333" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#228822" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#aa6622" } } diff --git a/themes/monochrome-dark.json b/themes/monochrome-dark.json index 5491197..5ce04d1 100644 --- a/themes/monochrome-dark.json +++ b/themes/monochrome-dark.json @@ -73,7 +73,7 @@ "color": "#666666" }, "FILE_TREE_DIR_COLOR": { - "color": "#cccccc" + "color": "#ffffff" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#ffffff" @@ -83,5 +83,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#888888" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#66cc66" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#cc9944" } } diff --git a/themes/monochrome-light.json b/themes/monochrome-light.json index 2974808..b71ae28 100644 --- a/themes/monochrome-light.json +++ b/themes/monochrome-light.json @@ -60,7 +60,7 @@ }, "FILE_TREE_COLOR": { "color": "#333333", - "backgroundColor": "#d0d0d0" + "backgroundColor": "#d5d5d5" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -69,7 +69,7 @@ }, "FILE_TREE_BORDER_COLOR": {}, "FILE_TREE_DIR_COLOR": { - "color": "#333333" + "color": "#000000" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#000000" @@ -79,5 +79,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#888888" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#228822" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#aa6622" } } diff --git a/themes/solarized-dark.json b/themes/solarized-dark.json index 97c82e3..99eb679 100644 --- a/themes/solarized-dark.json +++ b/themes/solarized-dark.json @@ -56,7 +56,7 @@ "MISSING_LINE_COLOR": {}, "FILE_TREE_COLOR": { "color": "#839496", - "backgroundColor": "#073642" + "backgroundColor": "#00232e" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -67,7 +67,7 @@ "color": "#93a1a1" }, "FILE_TREE_DIR_COLOR": { - "color": "#268bd2" + "color": "#93a1a1" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#b58900" @@ -77,5 +77,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#dc322f" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#859900" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#cb4b16" } } diff --git a/themes/solarized-light.json b/themes/solarized-light.json index ba6e107..7466a51 100644 --- a/themes/solarized-light.json +++ b/themes/solarized-light.json @@ -58,7 +58,7 @@ "MISSING_LINE_COLOR": {}, "FILE_TREE_COLOR": { "color": "#657b83", - "backgroundColor": "#eee8d5" + "backgroundColor": "#f5eedb" }, "FILE_TREE_SELECTED_COLOR": { "modifiers": [ @@ -69,7 +69,7 @@ "color": "#586e75" }, "FILE_TREE_DIR_COLOR": { - "color": "#268bd2" + "color": "#586e75" }, "FILE_TREE_BORDER_FOCUSED_COLOR": { "color": "#b58900" @@ -79,5 +79,11 @@ }, "FILE_TREE_DELETIONS_COLOR": { "color": "#dc322f" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#859900" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#cb4b16" } } From e5aed4e9adb7abd2025272fdce21e774e042be1a Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:51:37 +0000 Subject: [PATCH 07/18] Add git staging status detection with green/orange file coloring Co-Authored-By: Claude Opus 4.6 --- src/tui/TuiApp.ts | 10 ++++++++ src/tui/collectDiffData.ts | 3 +++ src/tui/gitStatus.ts | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 src/tui/gitStatus.ts diff --git a/src/tui/TuiApp.ts b/src/tui/TuiApp.ts index ca0d7e8..0e28fb4 100644 --- a/src/tui/TuiApp.ts +++ b/src/tui/TuiApp.ts @@ -8,6 +8,7 @@ import { FileTreePanel } from './FileTreePanel'; import { DiffViewPanel } from './DiffViewPanel'; import { collectDiffData, DiffData } from './collectDiffData'; import { syncTreeToDiff, syncDiffToTree } from './sync'; +import { getGitStagingStatus } from './gitStatus'; import { RESET } from './ansi'; const TREE_WIDTH = 30; @@ -35,6 +36,15 @@ export class TuiApp { return; } + // Annotate files with git staging status + const stagingMap = getGitStagingStatus(); + for (const file of this.data.files) { + const path = file.fileNameB || file.fileNameA; + if (path && stagingMap.has(path)) { + file.stagingStatus = stagingMap.get(path); + } + } + const rows = process.stdout.rows ?? 24; const cols = process.stdout.columns ?? 80; diff --git a/src/tui/collectDiffData.ts b/src/tui/collectDiffData.ts index 3e7db29..cac4ca9 100644 --- a/src/tui/collectDiffData.ts +++ b/src/tui/collectDiffData.ts @@ -8,6 +8,8 @@ import { iterSideBySideDiffsWithEvents, } from '../iterSideBySideDiffs'; +import { StagingStatus } from './gitStatus'; + export interface DiffFile { fileNameA: string; fileNameB: string; @@ -15,6 +17,7 @@ export interface DiffFile { startLineIndex: number; additions: number; deletions: number; + stagingStatus?: StagingStatus; } export interface DiffData { diff --git a/src/tui/gitStatus.ts b/src/tui/gitStatus.ts new file mode 100644 index 0000000..cbf18a6 --- /dev/null +++ b/src/tui/gitStatus.ts @@ -0,0 +1,49 @@ +import { execSync } from 'child_process'; + +export type StagingStatus = 'staged' | 'partial' | 'unstaged'; + +/** + * Run `git status --porcelain` and return a map of file path → staging status. + * Returns empty map if not in a git repo or git is unavailable. + */ +export function getGitStagingStatus(): Map { + const result = new Map(); + + try { + const output = execSync('git status --porcelain', { + encoding: 'utf-8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + for (const line of output.split('\n')) { + if (line.length < 4) continue; + + const x = line[0]; // index (staging area) + const y = line[1]; // working tree + + let filePath = line.slice(3); + + // Handle renames: "R old -> new" + const arrowIdx = filePath.indexOf(' -> '); + if (arrowIdx !== -1) { + filePath = filePath.slice(arrowIdx + 4); + } + + const xChanged = x !== ' ' && x !== '?'; + const yChanged = y !== ' ' && y !== '?'; + + if (xChanged && !yChanged) { + result.set(filePath, 'staged'); + } else if (xChanged && yChanged) { + result.set(filePath, 'partial'); + } else if (!xChanged && yChanged) { + result.set(filePath, 'unstaged'); + } + } + } catch { + // Not in a git repo or git not available — return empty + } + + return result; +} From 2cc4e2a32c86b06248b317a1fc0992ff03ce9afc Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:51:45 +0000 Subject: [PATCH 08/18] Add staged/partial-staged/file-selected theme colors, fix span ordering Co-Authored-By: Claude Opus 4.6 --- src/themes.ts | 3 +++ themes/arctic.json | 3 +++ themes/dark.json | 3 +++ themes/github-dark-dim.json | 3 +++ themes/github-light.json | 3 +++ themes/light.json | 3 +++ themes/monochrome-dark.json | 3 +++ themes/monochrome-light.json | 3 +++ themes/solarized-dark.json | 3 +++ themes/solarized-light.json | 3 +++ 10 files changed, 30 insertions(+) diff --git a/src/themes.ts b/src/themes.ts index e45050c..10d8abd 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -53,6 +53,7 @@ export enum ThemeColorName { FILE_TREE_BORDER_FOCUSED_COLOR = 'FILE_TREE_BORDER_FOCUSED_COLOR', FILE_TREE_ADDITIONS_COLOR = 'FILE_TREE_ADDITIONS_COLOR', FILE_TREE_DELETIONS_COLOR = 'FILE_TREE_DELETIONS_COLOR', + FILE_TREE_FILE_SELECTED_COLOR = 'FILE_TREE_FILE_SELECTED_COLOR', FILE_TREE_STAGED_COLOR = 'FILE_TREE_STAGED_COLOR', FILE_TREE_PARTIAL_STAGED_COLOR = 'FILE_TREE_PARTIAL_STAGED_COLOR', } @@ -65,6 +66,7 @@ const OPTIONAL_THEME_COLORS: Set = new Set([ ThemeColorName.FILE_TREE_BORDER_FOCUSED_COLOR, ThemeColorName.FILE_TREE_ADDITIONS_COLOR, ThemeColorName.FILE_TREE_DELETIONS_COLOR, + ThemeColorName.FILE_TREE_FILE_SELECTED_COLOR, ThemeColorName.FILE_TREE_STAGED_COLOR, ThemeColorName.FILE_TREE_PARTIAL_STAGED_COLOR, ]); @@ -82,6 +84,7 @@ const OPTIONAL_THEME_COLOR_DEFAULTS: Partial< [ThemeColorName.FILE_TREE_BORDER_FOCUSED_COLOR]: { color: '#ffdd99' }, [ThemeColorName.FILE_TREE_ADDITIONS_COLOR]: { color: '#66cc66' }, [ThemeColorName.FILE_TREE_DELETIONS_COLOR]: { color: '#cc6666' }, + [ThemeColorName.FILE_TREE_FILE_SELECTED_COLOR]: { backgroundColor: '#3a3a3a' }, [ThemeColorName.FILE_TREE_STAGED_COLOR]: { color: '#66cc66' }, [ThemeColorName.FILE_TREE_PARTIAL_STAGED_COLOR]: { color: '#cc9944' }, }; diff --git a/themes/arctic.json b/themes/arctic.json index 74fc81b..3ca6b36 100644 --- a/themes/arctic.json +++ b/themes/arctic.json @@ -92,5 +92,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#D08770" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#3a3a3a" } } diff --git a/themes/dark.json b/themes/dark.json index e9e538f..0f2895a 100644 --- a/themes/dark.json +++ b/themes/dark.json @@ -102,5 +102,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#cc9944" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#3a3a3a" } } diff --git a/themes/github-dark-dim.json b/themes/github-dark-dim.json index c341634..715d349 100644 --- a/themes/github-dark-dim.json +++ b/themes/github-dark-dim.json @@ -87,5 +87,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#c69026" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#283040" } } diff --git a/themes/github-light.json b/themes/github-light.json index 52d1b77..8aef5e7 100644 --- a/themes/github-light.json +++ b/themes/github-light.json @@ -98,5 +98,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#d15704" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#e8e8e8" } } diff --git a/themes/light.json b/themes/light.json index bec153a..faf2609 100644 --- a/themes/light.json +++ b/themes/light.json @@ -90,5 +90,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#aa6622" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#d0d0d0" } } diff --git a/themes/monochrome-dark.json b/themes/monochrome-dark.json index 5ce04d1..deb3949 100644 --- a/themes/monochrome-dark.json +++ b/themes/monochrome-dark.json @@ -89,5 +89,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#cc9944" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#333333" } } diff --git a/themes/monochrome-light.json b/themes/monochrome-light.json index b71ae28..73ed3e9 100644 --- a/themes/monochrome-light.json +++ b/themes/monochrome-light.json @@ -85,5 +85,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#aa6622" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#c0c0c0" } } diff --git a/themes/solarized-dark.json b/themes/solarized-dark.json index 99eb679..ce67aa7 100644 --- a/themes/solarized-dark.json +++ b/themes/solarized-dark.json @@ -83,5 +83,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#cb4b16" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#0a3a45" } } diff --git a/themes/solarized-light.json b/themes/solarized-light.json index 7466a51..1a7915a 100644 --- a/themes/solarized-light.json +++ b/themes/solarized-light.json @@ -85,5 +85,8 @@ }, "FILE_TREE_PARTIAL_STAGED_COLOR": { "color": "#cb4b16" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#e5ddc8" } } From 4096f5d0cbc9d459dd72652a24bdac5aacff5989 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Wed, 11 Feb 2026 21:51:49 +0000 Subject: [PATCH 09/18] Use background-only selection for files, keep inverse for dirs Co-Authored-By: Claude Opus 4.6 --- src/tui/FileTreePanel.ts | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/tui/FileTreePanel.ts b/src/tui/FileTreePanel.ts index 5cac2e1..af99677 100644 --- a/src/tui/FileTreePanel.ts +++ b/src/tui/FileTreePanel.ts @@ -291,6 +291,9 @@ export class FileTreePanel { FILE_TREE_DIR_COLOR, FILE_TREE_ADDITIONS_COLOR, FILE_TREE_DELETIONS_COLOR, + FILE_TREE_FILE_SELECTED_COLOR, + FILE_TREE_STAGED_COLOR, + FILE_TREE_PARTIAL_STAGED_COLOR, } = this.context; for (let row = 0; row < this.height; row++) { @@ -362,13 +365,26 @@ export class FileTreePanel { const line = T() .appendString(fullText) - .fillWidth(this.width, ' ') - .addSpan(0, this.width, FILE_TREE_COLOR); + .fillWidth(this.width, ' '); + + // Specific spans first (first-added wins in reduceThemeColors) + const staging = vn.node.file.stagingStatus; + if (staging === 'staged') { + line.addSpan( + prefix.length, + prefix.length + name.length, + FILE_TREE_STAGED_COLOR + ); + } else if (staging === 'partial') { + line.addSpan( + prefix.length, + prefix.length + name.length, + FILE_TREE_PARTIAL_STAGED_COLOR + ); + } - // Color the stat portion if (stat) { const statStart = this.width - suffix.length + 1; - // Color additions part if (adds > 0) { const addStr = `+${adds}`; const addStart = statStart; @@ -389,10 +405,14 @@ export class FileTreePanel { } } + // Selected bg before generic base so it wins the bg merge if (isSelected) { - line.addSpan(0, this.width, FILE_TREE_SELECTED_COLOR); + line.addSpan(0, this.width, FILE_TREE_FILE_SELECTED_COLOR); } + // Generic base color last (fallback for unspanned regions) + line.addSpan(0, this.width, FILE_TREE_COLOR); + screen.writeAt( screenRow, startCol, From c06368de4dae11e6f8b7ee674b3e0e3f550c74fa Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 00:14:06 +0000 Subject: [PATCH 10/18] Fix dead ternary in FileTreePanel, use execFileSync in gitStatus Co-Authored-By: Claude Opus 4.6 --- src/tui/FileTreePanel.ts | 3 +-- src/tui/gitStatus.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/tui/FileTreePanel.ts b/src/tui/FileTreePanel.ts index af99677..6988185 100644 --- a/src/tui/FileTreePanel.ts +++ b/src/tui/FileTreePanel.ts @@ -191,8 +191,7 @@ export class FileTreePanel { } // Navigate to parent directory - const nodePath = - vn.type === 'dir' ? vn.node.path : vn.node.path; + const nodePath = vn.node.path; const parentPath = path.dirname(nodePath); if (parentPath === '.' || parentPath === nodePath) return; diff --git a/src/tui/gitStatus.ts b/src/tui/gitStatus.ts index cbf18a6..23e3f5c 100644 --- a/src/tui/gitStatus.ts +++ b/src/tui/gitStatus.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; export type StagingStatus = 'staged' | 'partial' | 'unstaged'; @@ -10,7 +10,7 @@ export function getGitStagingStatus(): Map { const result = new Map(); try { - const output = execSync('git status --porcelain', { + const output = execFileSync('git', ['status', '--porcelain'], { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], From 9a533caf71883e3dfcc0230db8fdbec83aabdbd0 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 00:14:11 +0000 Subject: [PATCH 11/18] Deduplicate side-by-side diff parser into single iterSideBySideDiffEvents Co-Authored-By: Claude Opus 4.6 --- src/iterSideBySideDiffs.ts | 294 +------------------------------------ src/tui/collectDiffData.ts | 45 +++++- 2 files changed, 49 insertions(+), 290 deletions(-) diff --git a/src/iterSideBySideDiffs.ts b/src/iterSideBySideDiffs.ts index 89dbd24..eec1880 100644 --- a/src/iterSideBySideDiffs.ts +++ b/src/iterSideBySideDiffs.ts @@ -34,300 +34,22 @@ type State = | 'combined-diff-hunk-header' | 'combined-diff-hunk-body'; -async function* iterSideBySideDiffsFormatted( - context: Context, - lines: AsyncIterable -): AsyncIterable { - const { HORIZONTAL_SEPARATOR } = context; - - let state: State = 'unknown'; - - // Commit metadata - let isFirstCommitBodyLine = false; - - // File metadata - let fileNameA: string = ''; - let fileNameB: string = ''; - function* yieldFileName() { - yield* iterFormatFileName(context, fileNameA, fileNameB); - } - - // Hunk metadata - let hunkParts: HunkPart[] = []; - let hunkHeaderLine: string = ''; - async function* yieldHunk(diffType: 'unified-diff' | 'combined-diff') { - yield* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts); - for (const hunkPart of hunkParts) { - hunkPart.startLineNo = -1; - hunkPart.lines = []; - } - } - - async function* flushPending() { - if (state === 'unified-diff' || state === 'combined-diff') { - yield* yieldFileName(); - } else if (state === 'unified-diff-hunk-body') { - yield* yieldHunk('unified-diff'); - } else if (state === 'combined-diff-hunk-body') { - yield* yieldHunk('combined-diff'); - } - } - - for await (const rawLine of lines) { - const line = rawLine.replace(ANSI_COLOR_CODE_REGEX, ''); - - // Update state - let nextState: State | null = null; - if (line.startsWith('commit ')) { - nextState = 'commit-header'; - } else if (state === 'commit-header' && line.startsWith(' ')) { - nextState = 'commit-body'; - } else if (line.startsWith('diff --git')) { - nextState = 'unified-diff'; - } else if (line.startsWith('@@ ')) { - nextState = 'unified-diff-hunk-header'; - } else if (state === 'unified-diff-hunk-header') { - nextState = 'unified-diff-hunk-body'; - } else if ( - line.startsWith('diff --cc') || - line.startsWith('diff --combined') - ) { - nextState = 'combined-diff'; - } else if (COMBINED_HUNK_HEADER_START_REGEX.test(line)) { - nextState = 'combined-diff-hunk-header'; - } else if (state === 'combined-diff-hunk-header') { - nextState = 'combined-diff-hunk-body'; - } else if ( - state === 'commit-body' && - line.length > 0 && - !line.startsWith(' ') - ) { - nextState = 'unknown'; - } - - // Handle state starts - if (nextState) { - yield* flushPending(); - - switch (nextState) { - case 'commit-header': - if ( - state === 'unified-diff-hunk-header' || - state === 'unified-diff-hunk-body' - ) { - yield HORIZONTAL_SEPARATOR; - } - break; - case 'unified-diff': - fileNameA = ''; - fileNameB = ''; - break; - case 'unified-diff-hunk-header': - hunkParts = [ - { fileName: fileNameA, startLineNo: -1, lines: [] }, - { fileName: fileNameB, startLineNo: -1, lines: [] }, - ]; - break; - case 'commit-body': - isFirstCommitBodyLine = true; - break; - } - - state = nextState; - } - - // Handle state - switch (state) { - case 'unknown': { - yield T().appendString(rawLine); - break; - } - case 'commit-header': { - yield* iterFormatCommitHeaderLine(context, line); - break; - } - case 'commit-body': { - yield* iterFormatCommitBodyLine( - context, - line, - isFirstCommitBodyLine - ); - isFirstCommitBodyLine = false; - break; - } - case 'unified-diff': - case 'combined-diff': { - if (line.startsWith('--- a/')) { - fileNameA = line.slice('--- a/'.length); - } else if (line.startsWith('+++ b/')) { - fileNameB = line.slice('+++ b/'.length); - } else if (line.startsWith('--- ')) { - fileNameA = line.slice('--- '.length); - // https://git-scm.com/docs/diff-format says that - // `/dev/null` is used to indicate creations and deletions, - // so we can special case it. - if (fileNameA === '/dev/null') { - fileNameA = ''; - } - } else if (line.startsWith('+++ ')) { - fileNameB = line.slice('+++ '.length); - if (fileNameB === '/dev/null') { - fileNameB = ''; - } - } else if (line.startsWith('rename from ')) { - fileNameA = line.slice('rename from '.length); - } else if (line.startsWith('rename to ')) { - fileNameB = line.slice('rename to '.length); - } else if (line.startsWith('Binary files')) { - const match = line.match(BINARY_FILES_DIFF_REGEX); - if (match) { - [, fileNameA, fileNameB] = match; - } - } - break; - } - case 'unified-diff-hunk-header': { - const hunkHeaderStart = line.indexOf('@@ '); - const hunkHeaderEnd = line.indexOf(' @@', hunkHeaderStart + 1); - assert.ok(hunkHeaderStart >= 0); - assert.ok(hunkHeaderEnd > hunkHeaderStart); - const hunkHeader = line.slice( - hunkHeaderStart + 3, - hunkHeaderEnd - ); - hunkHeaderLine = line; - - const [aHeader, bHeader] = hunkHeader.split(' '); - const [startAString] = aHeader.split(','); - const [startBString] = bHeader.split(','); - - assert.ok(startAString.startsWith('-')); - hunkParts[0].startLineNo = parseInt(startAString.slice(1), 10); - - assert.ok(startBString.startsWith('+')); - hunkParts[1].startLineNo = parseInt(startBString.slice(1), 10); - break; - } - case 'unified-diff-hunk-body': { - const [{ lines: hunkLinesA }, { lines: hunkLinesB }] = - hunkParts; - if (line.startsWith('-')) { - hunkLinesA.push(line); - } else if (line.startsWith('+')) { - hunkLinesB.push(line); - } else { - while (hunkLinesA.length < hunkLinesB.length) { - hunkLinesA.push(null); - } - while (hunkLinesB.length < hunkLinesA.length) { - hunkLinesB.push(null); - } - hunkLinesA.push(line); - hunkLinesB.push(line); - } - break; - } - case 'combined-diff-hunk-header': { - const match = COMBINED_HUNK_HEADER_START_REGEX.exec(line); - assert.ok(match); - const hunkHeaderStart = match.index + match[0].length; // End of the opening "@@@ " - const hunkHeaderEnd = line.lastIndexOf(' ' + match[1]); // Start of the closing " @@@" - assert.ok(hunkHeaderStart >= 0); - assert.ok(hunkHeaderEnd > hunkHeaderStart); - const hunkHeader = line.slice(hunkHeaderStart, hunkHeaderEnd); - hunkHeaderLine = line; - - const fileRanges = hunkHeader.split(' '); - hunkParts = []; - for (let i = 0; i < fileRanges.length; i++) { - const fileRange = fileRanges[i]; - const [fileRangeStart] = fileRange.slice(1).split(','); - hunkParts.push({ - fileName: - i === fileRanges.length - 1 ? fileNameB : fileNameA, - startLineNo: parseInt(fileRangeStart, 10), - lines: [], - }); - } - break; - } - case 'combined-diff-hunk-body': { - // A combined diff works differently from a unified diff. See - // https://git-scm.com/docs/git-diff#_combined_diff_format for - // details, but essentially we get a row of prefixes in each - // line indicating whether the line is present on the parent, - // the current commit, or both. We convert this into N+1 parts - // (for N parents) where the first part shows the current state - // and the rest show changes made in the corresponding parent. - const linePrefix = line.slice(0, hunkParts.length - 1); - const lineSuffix = line.slice(hunkParts.length - 1); - const isLineAdded = linePrefix.includes('+'); - const isLineRemoved = linePrefix.includes('-'); - - // First N parts show changes made in the corresponding parent - // Either the line is going to be: - // 1. In the current commit and missing in some parents, which - // will have + prefixes, or - // 2. Missing in the current commit and present in some parents, - // which will have - prefixes. - // 3. Present in all commits, which will all have a space - // prefix. - let i = 0; - while (i < hunkParts.length - 1) { - const hunkPart = hunkParts[i]; - const partPrefix = linePrefix[i]; - if (isLineAdded) { - if (partPrefix === '+') { - hunkPart.lines.push(null); - } else { - hunkPart.lines.push('+' + lineSuffix); - } - } else if (isLineRemoved) { - if (partPrefix === '-') { - hunkPart.lines.push('-' + lineSuffix); - } else { - hunkPart.lines.push(null); - } - } else { - hunkPart.lines.push(' ' + lineSuffix); - } - i++; - } - // Final part shows the current state, so we just display the - // lines that exist in it without any highlighting. - if (isLineRemoved) { - hunkParts[i].lines.push('-' + lineSuffix); - } else if (isLineAdded) { - hunkParts[i].lines.push('+' + lineSuffix); - } else { - hunkParts[i].lines.push(' ' + lineSuffix); - } - - break; - } - } - } - - yield* flushPending(); -} +export type DiffEvent = + | { type: 'line'; content: FormattedString } + | { type: 'file-start'; fileNameA: string; fileNameB: string; additions: number; deletions: number }; export async function* iterSideBySideDiffs( context: Context, lines: AsyncIterable ) { - for await (const formattedString of iterSideBySideDiffsFormatted( - context, - lines - )) { - yield applyFormatting(context, formattedString); + for await (const event of iterSideBySideDiffEvents(context, lines)) { + if (event.type === 'line') { + yield applyFormatting(context, event.content); + } } } -export type DiffEvent = - | { type: 'line'; content: FormattedString } - | { type: 'file-start'; fileNameA: string; fileNameB: string; additions: number; deletions: number }; - -export async function* iterSideBySideDiffsWithEvents( +export async function* iterSideBySideDiffEvents( context: Context, lines: AsyncIterable ): AsyncIterable { diff --git a/src/tui/collectDiffData.ts b/src/tui/collectDiffData.ts index cac4ca9..b15d340 100644 --- a/src/tui/collectDiffData.ts +++ b/src/tui/collectDiffData.ts @@ -5,7 +5,7 @@ import { iterlinesFromReadable } from '../iterLinesFromReadable'; import { iterReplaceTabsWithSpaces } from '../iterReplaceTabsWithSpaces'; import { DiffEvent, - iterSideBySideDiffsWithEvents, + iterSideBySideDiffEvents, } from '../iterSideBySideDiffs'; import { StagingStatus } from './gitStatus'; @@ -24,6 +24,7 @@ export interface DiffData { files: DiffFile[]; allRenderedLines: string[]; fileBoundaries: number[]; + rawLines: string[]; } function getDisplayName(fileNameA: string, fileNameB: string): string { @@ -33,6 +34,33 @@ function getDisplayName(fileNameA: string, fileNameB: string): string { return `${fileNameA} → ${fileNameB}`; } +/** + * Re-render diff lines from raw input with a given context. + * Used for dynamic width changes (terminal resize, tree toggle). + */ +export async function rerenderDiffLines( + context: Context, + rawLines: string[] +): Promise<{ allRenderedLines: string[]; fileBoundaries: number[] }> { + const allRenderedLines: string[] = []; + const fileBoundaries: number[] = []; + + async function* iterLines() { + for (const line of rawLines) yield line; + } + + const events = iterSideBySideDiffEvents(context, iterLines()); + for await (const event of events) { + if (event.type === 'file-start') { + fileBoundaries.push(allRenderedLines.length); + } else { + allRenderedLines.push(applyFormatting(context, event.content)); + } + } + + return { allRenderedLines, fileBoundaries }; +} + export async function collectDiffData( context: Context, input: Readable @@ -40,15 +68,24 @@ export async function collectDiffData( const files: DiffFile[] = []; const allRenderedLines: string[] = []; const fileBoundaries: number[] = []; + const rawLines: string[] = []; const lines = iterReplaceTabsWithSpaces( context, iterlinesFromReadable(input) ); - const events: AsyncIterable = iterSideBySideDiffsWithEvents( + // Buffer raw lines for later re-rendering + async function* captureLines() { + for await (const line of lines) { + rawLines.push(line); + yield line; + } + } + + const events: AsyncIterable = iterSideBySideDiffEvents( context, - lines + captureLines() ); for await (const event of events) { @@ -68,5 +105,5 @@ export async function collectDiffData( } } - return { files, allRenderedLines, fileBoundaries }; + return { files, allRenderedLines, fileBoundaries, rawLines }; } From 6eb9fa771aaa3589aca936550ee3de3c309184d6 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 00:14:20 +0000 Subject: [PATCH 12/18] Add Windows fallback for interactive mode, deduplicate TREE_WIDTH/BORDER_WIDTH constants Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index e7e6adc..68725a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,22 +7,26 @@ import { getContextForConfig } from './context'; import { getGitConfig } from './getGitConfig'; import { transformContentsStreaming } from './transformContentsStreaming'; import { getConfig } from './getConfig'; -import { TuiApp } from './tui/TuiApp'; +import { TuiApp, TREE_WIDTH, BORDER_WIDTH } from './tui/TuiApp'; const execAsync = util.promisify(exec); -const TREE_WIDTH = 30; -const BORDER_WIDTH = 1; - async function main() { const { stdout: gitConfigString } = await execAsync('git config -l'); const gitConfig = getGitConfig(gitConfigString); const config = getConfig(gitConfig); - const isInteractive = + let isInteractive = process.argv.includes('--interactive') || process.argv.includes('-i') || config.INTERACTIVE; + if (isInteractive && process.platform === 'win32') { + process.stderr.write( + 'Interactive mode is not supported on Windows. Falling back to non-interactive mode.\n' + ); + isInteractive = false; + } + const termCols = terminalSize().columns; const screenWidth = isInteractive ? termCols - TREE_WIDTH - BORDER_WIDTH From df4fa57e566bc6f2f1e376326788fff6d38ae4d0 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 00:14:23 +0000 Subject: [PATCH 13/18] Fix stale rendering artifacts with ERASE_TO_EOL and screen clear Co-Authored-By: Claude Opus 4.6 --- src/tui/Screen.ts | 6 ++++++ src/tui/ansi.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/src/tui/Screen.ts b/src/tui/Screen.ts index 9bafcd1..2c95df7 100644 --- a/src/tui/Screen.ts +++ b/src/tui/Screen.ts @@ -7,6 +7,7 @@ import { SHOW_CURSOR, CLEAR_SCREEN, CLEAR_LINE, + ERASE_TO_EOL, moveTo, RESET, } from './ansi'; @@ -29,6 +30,10 @@ export class Screen { this.flush(); } + clear(): void { + this.buffer += CLEAR_SCREEN; + } + exit(): void { this.write(SHOW_CURSOR + EXIT_ALT_SCREEN); this.flush(); @@ -53,6 +58,7 @@ export class Screen { writeAt(row: number, col: number, text: string, maxWidth: number): void { this.buffer += moveTo(row, col); this.buffer += truncateAnsi(text, maxWidth); + this.buffer += ERASE_TO_EOL; } clearLine(row: number): void { diff --git a/src/tui/ansi.ts b/src/tui/ansi.ts index 55ce76f..26b4c0a 100644 --- a/src/tui/ansi.ts +++ b/src/tui/ansi.ts @@ -16,6 +16,7 @@ export function moveTo(row: number, col: number): string { // Clearing export const CLEAR_SCREEN = `${ESC}[2J`; export const CLEAR_LINE = `${ESC}[2K`; +export const ERASE_TO_EOL = `${ESC}[0K`; // Style export const RESET = `${ESC}[0m`; From 4086e020a53c90e38ebe25b9a43943281b219e41 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 00:14:27 +0000 Subject: [PATCH 14/18] Add 'e' key tree toggle and dynamic diff re-render on terminal resize Co-Authored-By: Claude Opus 4.6 --- src/tui/DiffViewPanel.ts | 5 ++ src/tui/TuiApp.ts | 106 ++++++++++++++++++++++++++++++--------- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/src/tui/DiffViewPanel.ts b/src/tui/DiffViewPanel.ts index 6f70fc0..96c08d2 100644 --- a/src/tui/DiffViewPanel.ts +++ b/src/tui/DiffViewPanel.ts @@ -23,6 +23,11 @@ export class DiffViewPanel { this.clampScroll(); } + setLines(lines: string[]): void { + this.lines = lines; + this.clampScroll(); + } + scrollUp(n: number = 1): void { this.scrollOffset = Math.max(0, this.scrollOffset - n); } diff --git a/src/tui/TuiApp.ts b/src/tui/TuiApp.ts index 0e28fb4..b5cb35e 100644 --- a/src/tui/TuiApp.ts +++ b/src/tui/TuiApp.ts @@ -1,18 +1,18 @@ import { Readable } from 'stream'; import * as process from 'process'; -import { Context } from '../context'; +import { Context, getContextForConfig } from '../context'; import { applyFormatting, T } from '../formattedString'; import { Screen } from './Screen'; import { InputHandler } from './InputHandler'; import { FileTreePanel } from './FileTreePanel'; import { DiffViewPanel } from './DiffViewPanel'; -import { collectDiffData, DiffData } from './collectDiffData'; +import { collectDiffData, DiffData, rerenderDiffLines } from './collectDiffData'; import { syncTreeToDiff, syncDiffToTree } from './sync'; import { getGitStagingStatus } from './gitStatus'; import { RESET } from './ansi'; -const TREE_WIDTH = 30; -const BORDER_WIDTH = 1; +export const TREE_WIDTH = 30; +export const BORDER_WIDTH = 1; type FocusPanel = 'tree' | 'diff'; @@ -24,6 +24,8 @@ export class TuiApp { private data!: DiffData; private context!: Context; private focus: FocusPanel = 'tree'; + private treeVisible: boolean = true; + private lastDiffWidth: number = 0; private resolve!: () => void; async run(context: Context, stdin: Readable): Promise { @@ -53,6 +55,7 @@ export class TuiApp { const viewHeight = rows; const diffWidth = cols - TREE_WIDTH - BORDER_WIDTH; + this.lastDiffWidth = diffWidth; this.tree = new FileTreePanel( this.data.files, @@ -91,10 +94,52 @@ export class TuiApp { this.screen.cols = cols; const viewHeight = rows; - const diffWidth = cols - TREE_WIDTH - BORDER_WIDTH; + const treeWidth = this.treeVisible ? TREE_WIDTH : 0; + const borderWidth = this.treeVisible ? BORDER_WIDTH : 0; + const diffWidth = cols - treeWidth - borderWidth; - this.tree.resize(TREE_WIDTH, viewHeight); + this.tree.resize(treeWidth, viewHeight); this.diff.resize(diffWidth, viewHeight); + + if (diffWidth !== this.lastDiffWidth) { + this.lastDiffWidth = diffWidth; + this.reRenderDiff(diffWidth); + } else { + this.render(); + } + } + + private async reRenderDiff(screenWidth: number): Promise { + const newContext = await getContextForConfig( + this.context, + this.context.CHALK, + screenWidth + ); + const { allRenderedLines, fileBoundaries } = + await rerenderDiffLines(newContext, this.data.rawLines); + + // Preserve scroll position proportionally + const oldOffset = this.diff.scrollOffset; + const oldTotal = this.data.allRenderedLines.length; + + this.data.allRenderedLines = allRenderedLines; + this.data.fileBoundaries = fileBoundaries; + + // Update file start indices + for (let i = 0; i < this.data.files.length && i < fileBoundaries.length; i++) { + this.data.files[i].startLineIndex = fileBoundaries[i]; + } + + this.diff.setLines(allRenderedLines); + + // Restore scroll proportionally + if (oldTotal > 0) { + const ratio = oldOffset / oldTotal; + this.diff.scrollToLine( + Math.round(ratio * allRenderedLines.length) + ); + } + this.render(); } @@ -104,7 +149,17 @@ export class TuiApp { return; } + if (key === 'e') { + this.treeVisible = !this.treeVisible; + if (!this.treeVisible) { + this.focus = 'diff'; + } + this.handleResize(); + return; + } + if (key === 'tab') { + if (!this.treeVisible) return; this.focus = this.focus === 'tree' ? 'diff' : 'tree'; this.render(); return; @@ -262,7 +317,7 @@ export class TuiApp { break; case 'h': case 'left': - this.focus = 'tree'; + if (this.treeVisible) this.focus = 'tree'; break; case 'space': case 'pagedown': @@ -305,27 +360,30 @@ export class TuiApp { } private render(): void { - const borderCol = TREE_WIDTH; - const diffCol = TREE_WIDTH + BORDER_WIDTH; + this.screen.clear(); const viewHeight = this.screen.rows; - // Render tree panel - this.tree.render(this.screen, 0, 0, this.focus === 'tree'); + if (this.treeVisible) { + const borderCol = TREE_WIDTH; + const diffCol = TREE_WIDTH + BORDER_WIDTH; - // Render border — use focused color when tree is focused - const borderColor = this.focus === 'tree' - ? this.context.FILE_TREE_BORDER_FOCUSED_COLOR - : this.context.FILE_TREE_BORDER_COLOR; - const borderStyle = applyFormatting( - this.context, - T().appendString('│').addSpan(0, 1, borderColor) - ); - for (let r = 0; r < viewHeight; r++) { - this.screen.writeAt(r, borderCol, borderStyle + RESET, 1); - } + this.tree.render(this.screen, 0, 0, this.focus === 'tree'); - // Render diff panel - this.diff.render(this.screen, diffCol, 0); + const borderColor = this.focus === 'tree' + ? this.context.FILE_TREE_BORDER_FOCUSED_COLOR + : this.context.FILE_TREE_BORDER_COLOR; + const borderStyle = applyFormatting( + this.context, + T().appendString('│').addSpan(0, 1, borderColor) + ); + for (let r = 0; r < viewHeight; r++) { + this.screen.writeAt(r, borderCol, borderStyle + RESET, 1); + } + + this.diff.render(this.screen, diffCol, 0); + } else { + this.diff.render(this.screen, 0, 0); + } this.screen.flush(); } From a60803361f82b0b026da2daa0f701668f60fbd84 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 13:50:03 +0000 Subject: [PATCH 15/18] Fix ttyFd leak, remove dead params, restore comments, export helpers Co-Authored-By: Claude Opus 4.6 --- src/iterSideBySideDiffs.ts | 7 +++++++ src/tui/FileTreePanel.ts | 18 ++++++++---------- src/tui/InputHandler.ts | 9 +++++++-- src/tui/sync.ts | 2 +- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/iterSideBySideDiffs.ts b/src/iterSideBySideDiffs.ts index eec1880..7ed1773 100644 --- a/src/iterSideBySideDiffs.ts +++ b/src/iterSideBySideDiffs.ts @@ -197,6 +197,8 @@ export async function* iterSideBySideDiffEvents( fileNameB = line.slice('+++ b/'.length); } else if (line.startsWith('--- ')) { fileNameA = line.slice('--- '.length); + // /dev/null indicates file creation/deletion + // per git diff-format spec if (fileNameA === '/dev/null') { fileNameA = ''; } @@ -282,6 +284,10 @@ export async function* iterSideBySideDiffEvents( break; } case 'combined-diff-hunk-body': { + // Combined diffs have N+1 columns: first N show changes + // relative to each parent, last shows the merge result. + // Prefix chars: + (added), - (removed), space (unchanged). + // See: https://git-scm.com/docs/git-diff#_combined_diff_format const linePrefix = line.slice(0, hunkParts.length - 1); const lineSuffix = line.slice(hunkParts.length - 1); const isLineAdded = linePrefix.includes('+'); @@ -307,6 +313,7 @@ export async function* iterSideBySideDiffEvents( } i++; } + // Final part: the merge result (current commit state) if (isLineRemoved) { hunkParts[i].lines.push('-' + lineSuffix); fileDeletions++; diff --git a/src/tui/FileTreePanel.ts b/src/tui/FileTreePanel.ts index 6988185..cc09978 100644 --- a/src/tui/FileTreePanel.ts +++ b/src/tui/FileTreePanel.ts @@ -27,10 +27,10 @@ type FileNode = { type TreeNode = DirNode | FileNode; type VisibleNode = - | { type: 'dir'; node: DirNode; depth: number } - | { type: 'file'; node: FileNode; fileIndex: number; depth: number }; + | { type: 'dir'; node: DirNode } + | { type: 'file'; node: FileNode; fileIndex: number }; -function buildTree(files: DiffFile[]): TreeNode[] { +export function buildTree(files: DiffFile[]): TreeNode[] { const root: TreeNode[] = []; for (let i = 0; i < files.length; i++) { @@ -72,20 +72,19 @@ function buildTree(files: DiffFile[]): TreeNode[] { return root; } -function flattenVisible(nodes: TreeNode[], depth: number = 0): VisibleNode[] { +export function flattenVisible(nodes: TreeNode[]): VisibleNode[] { const result: VisibleNode[] = []; for (const node of nodes) { if (node.type === 'dir') { - result.push({ type: 'dir', node, depth }); + result.push({ type: 'dir', node }); if (node.expanded) { - result.push(...flattenVisible(node.children, depth + 1)); + result.push(...flattenVisible(node.children)); } } else { result.push({ type: 'file', node, fileIndex: node.fileIndex, - depth, }); } } @@ -281,8 +280,7 @@ export class FileTreePanel { render( screen: Screen, startCol: number, - startRow: number, - focused: boolean + startRow: number ): void { const { FILE_TREE_COLOR, @@ -314,7 +312,7 @@ export class FileTreePanel { const vn = this.visibleNodes[visIdx]; const isSelected = visIdx === this.selectedIndex; - const indent = ' '.repeat(vn.depth); + const indent = ' '.repeat(vn.node.depth); if (vn.type === 'dir') { const icon = vn.node.expanded ? FOLDER_OPEN : FOLDER_CLOSED; diff --git a/src/tui/InputHandler.ts b/src/tui/InputHandler.ts index ab1e465..6aef9a5 100644 --- a/src/tui/InputHandler.ts +++ b/src/tui/InputHandler.ts @@ -6,6 +6,7 @@ export type KeyHandler = (key: string, ctrl: boolean) => void; export class InputHandler { private ttyStream: tty.ReadStream | null = null; + private ttyFd: number = -1; private handler: KeyHandler; constructor(handler: KeyHandler) { @@ -13,8 +14,8 @@ export class InputHandler { } start(): void { - const fd = fs.openSync('/dev/tty', 'r'); - this.ttyStream = new tty.ReadStream(fd); + this.ttyFd = fs.openSync('/dev/tty', 'r'); + this.ttyStream = new tty.ReadStream(this.ttyFd); this.ttyStream.setEncoding('utf-8'); this.ttyStream.setRawMode(true); @@ -36,5 +37,9 @@ export class InputHandler { this.ttyStream.destroy(); this.ttyStream = null; } + if (this.ttyFd >= 0) { + try { fs.closeSync(this.ttyFd); } catch {} + this.ttyFd = -1; + } } } diff --git a/src/tui/sync.ts b/src/tui/sync.ts index 451a95c..f4924d3 100644 --- a/src/tui/sync.ts +++ b/src/tui/sync.ts @@ -37,7 +37,7 @@ export function syncDiffToTree( /** * Binary search: find the last boundary <= topLine. */ -function binarySearchBoundary( +export function binarySearchBoundary( boundaries: number[], topLine: number ): number { From 211ffe479c1c94dc9ca60ef97d98f67fff4f91a5 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 13:50:06 +0000 Subject: [PATCH 16/18] Add unit tests for TUI helpers Co-Authored-By: Claude Opus 4.6 --- src/tui/tui.test.ts | 173 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src/tui/tui.test.ts diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts new file mode 100644 index 0000000..9bf1d6c --- /dev/null +++ b/src/tui/tui.test.ts @@ -0,0 +1,173 @@ +import { truncateAnsi } from './Screen'; +import { getFileIcon } from './fileIcons'; +import { binarySearchBoundary } from './sync'; +import { buildTree, flattenVisible } from './FileTreePanel'; +import { DiffFile } from './collectDiffData'; + +const RESET = '\x1b[0m'; + +describe('truncateAnsi', () => { + test('plain text within limit', () => { + expect(truncateAnsi('hello', 10)).toBe('hello'); + }); + + test('plain text exceeding limit', () => { + const result = truncateAnsi('hello world', 5); + expect(result).toBe('hello' + RESET); + }); + + test('zero width returns reset only', () => { + const result = truncateAnsi('hello', 0); + expect(result).toBe(RESET); + }); + + test('preserves ANSI escapes, truncates visible chars', () => { + const input = '\x1b[31mhello\x1b[0m world'; + const result = truncateAnsi(input, 5); + // Should contain 'hello' colored red but not ' world' + expect(result).toContain('\x1b[31m'); + expect(result.replace(/\x1b\[[0-9;]*m/g, '')).toBe('hello'); + }); + + test('empty string returns empty', () => { + expect(truncateAnsi('', 10)).toBe(''); + }); + + test('exact width returns unchanged', () => { + expect(truncateAnsi('abcde', 5)).toBe('abcde'); + }); +}); + +describe('getFileIcon', () => { + test('.ts extension', () => { + expect(getFileIcon('src/foo.ts')).toBe('\ue628'); + }); + + test('.tsx extension', () => { + expect(getFileIcon('Component.tsx')).toBe('\ue7ba'); + }); + + test('unknown extension returns default', () => { + expect(getFileIcon('file.xyz')).toBe('\uf15b'); + }); + + test('Dockerfile without extension', () => { + expect(getFileIcon('Dockerfile')).toBe('\ue7b0'); + }); + + test('Dockerfile with suffix', () => { + expect(getFileIcon('Dockerfile.prod')).toBe('\ue7b0'); + }); + + test('.gitignore', () => { + expect(getFileIcon('.gitignore')).toBe('\ue702'); + }); + + test('case-insensitive extension', () => { + expect(getFileIcon('README.MD')).toBe('\ue73e'); + }); +}); + +describe('binarySearchBoundary', () => { + test('single boundary', () => { + expect(binarySearchBoundary([0], 5)).toBe(0); + }); + + test('exact match returns that index', () => { + expect(binarySearchBoundary([0, 10, 20], 10)).toBe(1); + }); + + test('between boundaries returns lower', () => { + expect(binarySearchBoundary([0, 10, 20], 15)).toBe(1); + }); + + test('before first boundary returns 0', () => { + expect(binarySearchBoundary([5, 10, 20], 2)).toBe(0); + }); + + test('after last boundary returns last index', () => { + expect(binarySearchBoundary([0, 10, 20], 100)).toBe(2); + }); + + test('at first boundary returns 0', () => { + expect(binarySearchBoundary([0, 10, 20], 0)).toBe(0); + }); + + test('at last boundary returns last index', () => { + expect(binarySearchBoundary([0, 10, 20], 20)).toBe(2); + }); +}); + +function makeDiffFile(fileNameB: string): DiffFile { + return { + displayName: fileNameB, + fileNameA: '', + fileNameB, + additions: 0, + deletions: 0, + startLineIndex: 0, + }; +} + +describe('buildTree', () => { + test('single file at root', () => { + const tree = buildTree([makeDiffFile('README.md')]); + expect(tree).toHaveLength(1); + expect(tree[0].type).toBe('file'); + expect(tree[0].name).toBe('README.md'); + }); + + test('nested file creates dir nodes', () => { + const tree = buildTree([makeDiffFile('src/tui/App.ts')]); + expect(tree).toHaveLength(1); + expect(tree[0].type).toBe('dir'); + expect(tree[0].name).toBe('src'); + const src = tree[0] as { children: any[] }; + expect(src.children[0].type).toBe('dir'); + expect(src.children[0].name).toBe('tui'); + }); + + test('sibling files share dir node', () => { + const tree = buildTree([ + makeDiffFile('src/a.ts'), + makeDiffFile('src/b.ts'), + ]); + expect(tree).toHaveLength(1); + expect(tree[0].type).toBe('dir'); + const src = tree[0] as { children: any[] }; + expect(src.children).toHaveLength(2); + }); +}); + +describe('flattenVisible', () => { + test('returns dirs and files in order', () => { + const tree = buildTree([ + makeDiffFile('src/a.ts'), + makeDiffFile('src/b.ts'), + makeDiffFile('README.md'), + ]); + const visible = flattenVisible(tree); + // dir 'src', file 'a.ts', file 'b.ts', file 'README.md' + expect(visible).toHaveLength(4); + expect(visible[0].type).toBe('dir'); + expect(visible[1].type).toBe('file'); + expect(visible[2].type).toBe('file'); + expect(visible[3].type).toBe('file'); + }); + + test('collapsed dir hides children', () => { + const tree = buildTree([ + makeDiffFile('src/a.ts'), + makeDiffFile('README.md'), + ]); + // Collapse 'src' + const srcDir = tree[0] as { expanded: boolean }; + srcDir.expanded = false; + const visible = flattenVisible(tree); + // dir 'src' (collapsed), file 'README.md' + expect(visible).toHaveLength(2); + expect(visible[0].type).toBe('dir'); + expect(visible[1].type).toBe('file'); + expect(visible[1].node.name).toBe('README.md'); + }); +}); From 0be5f8543d54f78ad1bb7a9dfbf0c6d61c5f7488 Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Feb 2026 13:50:11 +0000 Subject: [PATCH 17/18] Make tree width configurable via split-diffs.tree-width Co-Authored-By: Claude Opus 4.6 --- src/getConfig.ts | 2 ++ src/getGitConfig.test.ts | 4 ++++ src/getGitConfig.ts | 13 +++++++++++++ src/index.test.ts | 1 + src/index.ts | 6 +++--- src/previewTheme.ts | 1 + src/tui/TuiApp.ts | 19 ++++++++++--------- 7 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/getConfig.ts b/src/getConfig.ts index 97c66d7..2bbd9f9 100644 --- a/src/getConfig.ts +++ b/src/getConfig.ts @@ -7,6 +7,7 @@ export type Config = Theme & { WRAP_LINES: boolean; HIGHLIGHT_LINE_CHANGES: boolean; INTERACTIVE: boolean; + TREE_WIDTH: number; }; export const CONFIG_DEFAULTS: Omit = { @@ -14,6 +15,7 @@ export const CONFIG_DEFAULTS: Omit = { WRAP_LINES: true, HIGHLIGHT_LINE_CHANGES: true, INTERACTIVE: false, + TREE_WIDTH: 30, }; export function getConfig(gitConfig: GitConfig): Config { diff --git a/src/getGitConfig.test.ts b/src/getGitConfig.test.ts index bdf3caa..943ca77 100644 --- a/src/getGitConfig.test.ts +++ b/src/getGitConfig.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_MIN_LINE_WIDTH, DEFAULT_THEME_DIRECTORY, DEFAULT_THEME_NAME, + DEFAULT_TREE_WIDTH, GitConfig, getGitConfig, } from './getGitConfig'; @@ -13,6 +14,7 @@ const DEFAULT_CONFIG: GitConfig = { THEME_NAME: DEFAULT_THEME_NAME, THEME_DIRECTORY: DEFAULT_THEME_DIRECTORY, INTERACTIVE: false, + TREE_WIDTH: DEFAULT_TREE_WIDTH, }; describe('getGitConfig', () => { @@ -29,6 +31,7 @@ split-diffs.min-line-width=40 split-diffs.theme-name=arctic split-diffs.theme-directory=/tmp split-diffs.syntax-highlighting-theme=dark-plus +split-diffs.tree-width=40 `) ).toEqual({ WRAP_LINES: false, @@ -38,6 +41,7 @@ split-diffs.syntax-highlighting-theme=dark-plus THEME_DIRECTORY: '/tmp', SYNTAX_HIGHLIGHTING_THEME: 'dark-plus', INTERACTIVE: false, + TREE_WIDTH: 40, }); }); diff --git a/src/getGitConfig.ts b/src/getGitConfig.ts index 2a04231..f0ec22f 100644 --- a/src/getGitConfig.ts +++ b/src/getGitConfig.ts @@ -9,9 +9,11 @@ export type GitConfig = { THEME_NAME: string; SYNTAX_HIGHLIGHTING_THEME?: string; INTERACTIVE: boolean; + TREE_WIDTH: number; }; export const DEFAULT_MIN_LINE_WIDTH = 80; +export const DEFAULT_TREE_WIDTH = 30; export const DEFAULT_THEME_DIRECTORY = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', @@ -50,6 +52,16 @@ export function getGitConfig(configString: string): GitConfig { // Ignore invalid values } + let treeWidth = DEFAULT_TREE_WIDTH; + try { + const parsedTreeWidth = parseInt(rawConfig['tree-width'], 10); + if (!isNaN(parsedTreeWidth)) { + treeWidth = parsedTreeWidth; + } + } catch { + // Ignore invalid values + } + return { MIN_LINE_WIDTH: minLineWidth, WRAP_LINES: rawConfig['wrap-lines'] !== 'false', @@ -59,5 +71,6 @@ export function getGitConfig(configString: string): GitConfig { THEME_NAME: rawConfig['theme-name'] ?? DEFAULT_THEME_NAME, SYNTAX_HIGHLIGHTING_THEME: rawConfig['syntax-highlighting-theme'], INTERACTIVE: rawConfig['interactive'] === 'true', + TREE_WIDTH: treeWidth, }; } diff --git a/src/index.test.ts b/src/index.test.ts index 37dc717..b89eb66 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -25,6 +25,7 @@ const TEST_CONFIG: Config = { WRAP_LINES: false, HIGHLIGHT_LINE_CHANGES: false, INTERACTIVE: false, + TREE_WIDTH: 30, ...TEST_THEME, }; diff --git a/src/index.ts b/src/index.ts index 68725a1..a1d0117 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { getContextForConfig } from './context'; import { getGitConfig } from './getGitConfig'; import { transformContentsStreaming } from './transformContentsStreaming'; import { getConfig } from './getConfig'; -import { TuiApp, TREE_WIDTH, BORDER_WIDTH } from './tui/TuiApp'; +import { TuiApp, BORDER_WIDTH } from './tui/TuiApp'; const execAsync = util.promisify(exec); async function main() { @@ -29,14 +29,14 @@ async function main() { const termCols = terminalSize().columns; const screenWidth = isInteractive - ? termCols - TREE_WIDTH - BORDER_WIDTH + ? Math.max(1, termCols - config.TREE_WIDTH - BORDER_WIDTH) : termCols; const context = await getContextForConfig(config, chalk, screenWidth); if (isInteractive) { const app = new TuiApp(); - await app.run(context, process.stdin); + await app.run(context, process.stdin, config.TREE_WIDTH); } else { await transformContentsStreaming( context, diff --git a/src/previewTheme.ts b/src/previewTheme.ts index f68096b..3a16715 100644 --- a/src/previewTheme.ts +++ b/src/previewTheme.ts @@ -13,6 +13,7 @@ const CONFIG = { WRAP_LINES: true, HIGHLIGHT_LINE_CHANGES: true, INTERACTIVE: false, + TREE_WIDTH: 30, }; async function previewTheme( diff --git a/src/tui/TuiApp.ts b/src/tui/TuiApp.ts index b5cb35e..9677df6 100644 --- a/src/tui/TuiApp.ts +++ b/src/tui/TuiApp.ts @@ -11,7 +11,6 @@ import { syncTreeToDiff, syncDiffToTree } from './sync'; import { getGitStagingStatus } from './gitStatus'; import { RESET } from './ansi'; -export const TREE_WIDTH = 30; export const BORDER_WIDTH = 1; type FocusPanel = 'tree' | 'diff'; @@ -26,10 +25,12 @@ export class TuiApp { private focus: FocusPanel = 'tree'; private treeVisible: boolean = true; private lastDiffWidth: number = 0; + private treeWidth: number = 30; private resolve!: () => void; - async run(context: Context, stdin: Readable): Promise { + async run(context: Context, stdin: Readable, treeWidth: number = 30): Promise { this.context = context; + this.treeWidth = treeWidth; // Consume all diff data from stdin first this.data = await collectDiffData(context, stdin); @@ -54,12 +55,12 @@ export class TuiApp { this.screen.enter(); const viewHeight = rows; - const diffWidth = cols - TREE_WIDTH - BORDER_WIDTH; + const diffWidth = Math.max(1, cols - this.treeWidth - BORDER_WIDTH); this.lastDiffWidth = diffWidth; this.tree = new FileTreePanel( this.data.files, - TREE_WIDTH, + this.treeWidth, viewHeight, context ); @@ -94,9 +95,9 @@ export class TuiApp { this.screen.cols = cols; const viewHeight = rows; - const treeWidth = this.treeVisible ? TREE_WIDTH : 0; + const treeWidth = this.treeVisible ? this.treeWidth : 0; const borderWidth = this.treeVisible ? BORDER_WIDTH : 0; - const diffWidth = cols - treeWidth - borderWidth; + const diffWidth = Math.max(1, cols - treeWidth - borderWidth); this.tree.resize(treeWidth, viewHeight); this.diff.resize(diffWidth, viewHeight); @@ -364,10 +365,10 @@ export class TuiApp { const viewHeight = this.screen.rows; if (this.treeVisible) { - const borderCol = TREE_WIDTH; - const diffCol = TREE_WIDTH + BORDER_WIDTH; + const borderCol = this.treeWidth; + const diffCol = this.treeWidth + BORDER_WIDTH; - this.tree.render(this.screen, 0, 0, this.focus === 'tree'); + this.tree.render(this.screen, 0, 0); const borderColor = this.focus === 'tree' ? this.context.FILE_TREE_BORDER_FOCUSED_COLOR From 6f29eeee8849462adb2265aa23d7516c9bbb20ec Mon Sep 17 00:00:00 2001 From: armanarutiunov Date: Thu, 12 Mar 2026 18:54:10 +0000 Subject: [PATCH 18/18] Add 'f' key to toggle flat/folder file tree mode Co-Authored-By: Claude Opus 4.6 --- src/tui/FileTreePanel.ts | 41 +++++++++++++++++++++++++++++++++++++++- src/tui/TuiApp.ts | 6 ++++++ src/tui/tui.test.ts | 39 +++++++++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/tui/FileTreePanel.ts b/src/tui/FileTreePanel.ts index cc09978..25fef03 100644 --- a/src/tui/FileTreePanel.ts +++ b/src/tui/FileTreePanel.ts @@ -91,10 +91,26 @@ export function flattenVisible(nodes: TreeNode[]): VisibleNode[] { return result; } +export function flattenFlat(files: DiffFile[]): VisibleNode[] { + return files.map((file, i) => ({ + type: 'file' as const, + node: { + type: 'file' as const, + name: file.fileNameB || file.fileNameA || file.displayName, + path: file.fileNameB || file.fileNameA || file.displayName, + file, + fileIndex: i, + depth: 0, + }, + fileIndex: i, + })); +} + export class FileTreePanel { private files: DiffFile[]; private rootNodes: TreeNode[]; private visibleNodes: VisibleNode[] = []; + private flatMode: boolean = false; private width: number; private height: number; private context: Context; @@ -120,7 +136,30 @@ export class FileTreePanel { } private regenerateVisible(): void { - this.visibleNodes = flattenVisible(this.rootNodes); + this.visibleNodes = this.flatMode + ? flattenFlat(this.files) + : flattenVisible(this.rootNodes); + } + + toggleFlatMode(): void { + const selectedFileIndex = this.getSelectedFileIndex(); + this.flatMode = !this.flatMode; + this.regenerateVisible(); + if (selectedFileIndex != null) { + for (let i = 0; i < this.visibleNodes.length; i++) { + const vn = this.visibleNodes[i]; + if (vn.type === 'file' && vn.fileIndex === selectedFileIndex) { + this.selectedIndex = i; + this.ensureVisible(); + return; + } + } + } + this.selectedIndex = Math.min( + this.selectedIndex, + Math.max(0, this.visibleNodes.length - 1) + ); + this.ensureVisible(); } resize(width: number, height: number): void { diff --git a/src/tui/TuiApp.ts b/src/tui/TuiApp.ts index 9677df6..3e7cb8e 100644 --- a/src/tui/TuiApp.ts +++ b/src/tui/TuiApp.ts @@ -159,6 +159,12 @@ export class TuiApp { return; } + if (key === 'f') { + this.tree.toggleFlatMode(); + this.render(); + return; + } + if (key === 'tab') { if (!this.treeVisible) return; this.focus = this.focus === 'tree' ? 'diff' : 'tree'; diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 9bf1d6c..6688d16 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,7 +1,7 @@ import { truncateAnsi } from './Screen'; import { getFileIcon } from './fileIcons'; import { binarySearchBoundary } from './sync'; -import { buildTree, flattenVisible } from './FileTreePanel'; +import { buildTree, flattenVisible, flattenFlat } from './FileTreePanel'; import { DiffFile } from './collectDiffData'; const RESET = '\x1b[0m'; @@ -171,3 +171,40 @@ describe('flattenVisible', () => { expect(visible[1].node.name).toBe('README.md'); }); }); + +describe('flattenFlat', () => { + test('returns only files with full paths, no dirs', () => { + const files = [ + makeDiffFile('src/tui/App.ts'), + makeDiffFile('src/utils.ts'), + makeDiffFile('README.md'), + ]; + const visible = flattenFlat(files); + expect(visible).toHaveLength(3); + expect(visible.every((v) => v.type === 'file')).toBe(true); + expect(visible[0].node.name).toBe('src/tui/App.ts'); + expect(visible[1].node.name).toBe('src/utils.ts'); + expect(visible[2].node.name).toBe('README.md'); + }); + + test('all nodes have depth 0', () => { + const files = [ + makeDiffFile('a/b/c.ts'), + makeDiffFile('d.ts'), + ]; + const visible = flattenFlat(files); + expect(visible.every((v) => v.node.depth === 0)).toBe(true); + }); + + test('preserves file indices', () => { + const files = [ + makeDiffFile('x.ts'), + makeDiffFile('y.ts'), + makeDiffFile('z.ts'), + ]; + const visible = flattenFlat(files); + expect(visible[0].type === 'file' && visible[0].fileIndex).toBe(0); + expect(visible[1].type === 'file' && visible[1].fileIndex).toBe(1); + expect(visible[2].type === 'file' && visible[2].fileIndex).toBe(2); + }); +});