diff --git a/src/getConfig.ts b/src/getConfig.ts index b63b5c6..2bbd9f9 100644 --- a/src/getConfig.ts +++ b/src/getConfig.ts @@ -6,12 +6,16 @@ export type Config = Theme & { MIN_LINE_WIDTH: number; WRAP_LINES: boolean; HIGHLIGHT_LINE_CHANGES: boolean; + INTERACTIVE: boolean; + TREE_WIDTH: number; }; export const CONFIG_DEFAULTS: Omit = { MIN_LINE_WIDTH: 80, 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 559a9f8..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'; @@ -12,6 +13,8 @@ const DEFAULT_CONFIG: GitConfig = { MIN_LINE_WIDTH: DEFAULT_MIN_LINE_WIDTH, THEME_NAME: DEFAULT_THEME_NAME, THEME_DIRECTORY: DEFAULT_THEME_DIRECTORY, + INTERACTIVE: false, + TREE_WIDTH: DEFAULT_TREE_WIDTH, }; describe('getGitConfig', () => { @@ -28,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, @@ -36,6 +40,8 @@ split-diffs.syntax-highlighting-theme=dark-plus THEME_NAME: 'arctic', THEME_DIRECTORY: '/tmp', SYNTAX_HIGHLIGHTING_THEME: 'dark-plus', + INTERACTIVE: false, + TREE_WIDTH: 40, }); }); diff --git a/src/getGitConfig.ts b/src/getGitConfig.ts index 5c0107c..f0ec22f 100644 --- a/src/getGitConfig.ts +++ b/src/getGitConfig.ts @@ -8,9 +8,12 @@ export type GitConfig = { THEME_DIRECTORY: string; 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)), '..', @@ -49,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', @@ -57,5 +70,7 @@ 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', + TREE_WIDTH: treeWidth, }; } diff --git a/src/index.test.ts b/src/index.test.ts index 31025bf..b89eb66 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -24,6 +24,8 @@ const TEST_CONFIG: Config = { MIN_LINE_WIDTH: 60, 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 e1a0859..a1d0117 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,26 +7,51 @@ import { getContextForConfig } from './context'; import { getGitConfig } from './getGitConfig'; import { transformContentsStreaming } from './transformContentsStreaming'; import { getConfig } from './getConfig'; +import { TuiApp, BORDER_WIDTH } from './tui/TuiApp'; const execAsync = util.promisify(exec); 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); - }); + + 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 + ? 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, config.TREE_WIDTH); + } 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..7ed1773 100644 --- a/src/iterSideBySideDiffs.ts +++ b/src/iterSideBySideDiffs.ts @@ -34,28 +34,43 @@ type State = | 'combined-diff-hunk-header' | 'combined-diff-hunk-body'; -async function* iterSideBySideDiffsFormatted( +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 event of iterSideBySideDiffEvents(context, lines)) { + if (event.type === 'line') { + yield applyFormatting(context, event.content); + } + } +} + +export async function* iterSideBySideDiffEvents( context: Context, lines: AsyncIterable -): 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() { + let fileAdditions: number = 0; + let fileDeletions: number = 0; + let hunkParts: HunkPart[] = []; + let hunkHeaderLine: string = ''; + + function* yieldFileNameLines() { yield* iterFormatFileName(context, fileNameA, fileNameB); } - // Hunk metadata - let hunkParts: HunkPart[] = []; - let hunkHeaderLine: string = ''; - async function* yieldHunk(diffType: 'unified-diff' | 'combined-diff') { + async function* yieldHunkLines( + diffType: 'unified-diff' | 'combined-diff' + ) { yield* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts); for (const hunkPart of hunkParts) { hunkPart.startLineNo = -1; @@ -63,20 +78,32 @@ async function* iterSideBySideDiffsFormatted( } } - async function* flushPending() { + async function* flushPendingEvents(): AsyncIterable { if (state === 'unified-diff' || state === 'combined-diff') { - yield* yieldFileName(); + yield { + type: 'file-start', + fileNameA, + fileNameB, + additions: fileAdditions, + deletions: fileDeletions, + }; + for (const line of yieldFileNameLines()) { + yield { type: 'line', content: line }; + } } else if (state === 'unified-diff-hunk-body') { - yield* yieldHunk('unified-diff'); + for await (const line of yieldHunkLines('unified-diff')) { + yield { type: 'line', content: line }; + } } else if (state === 'combined-diff-hunk-body') { - yield* yieldHunk('combined-diff'); + 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, ''); - // Update state let nextState: State | null = null; if (line.startsWith('commit ')) { nextState = 'commit-header'; @@ -105,9 +132,8 @@ async function* iterSideBySideDiffsFormatted( nextState = 'unknown'; } - // Handle state starts if (nextState) { - yield* flushPending(); + yield* flushPendingEvents(); switch (nextState) { case 'commit-header': @@ -115,12 +141,17 @@ async function* iterSideBySideDiffsFormatted( state === 'unified-diff-hunk-header' || state === 'unified-diff-hunk-body' ) { - yield HORIZONTAL_SEPARATOR; + yield { + type: 'line', + content: HORIZONTAL_SEPARATOR, + }; } break; case 'unified-diff': fileNameA = ''; fileNameB = ''; + fileAdditions = 0; + fileDeletions = 0; break; case 'unified-diff-hunk-header': hunkParts = [ @@ -136,22 +167,25 @@ async function* iterSideBySideDiffsFormatted( state = nextState; } - // Handle state switch (state) { case 'unknown': { - yield T().appendString(rawLine); + yield { type: 'line', content: T().appendString(rawLine) }; break; } case 'commit-header': { - yield* iterFormatCommitHeaderLine(context, line); + for (const l of iterFormatCommitHeaderLine(context, line)) { + yield { type: 'line', content: l }; + } break; } case 'commit-body': { - yield* iterFormatCommitBodyLine( + for (const l of iterFormatCommitBodyLine( context, line, isFirstCommitBodyLine - ); + )) { + yield { type: 'line', content: l }; + } isFirstCommitBodyLine = false; break; } @@ -163,9 +197,8 @@ async function* iterSideBySideDiffsFormatted( 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. + // /dev/null indicates file creation/deletion + // per git diff-format spec if (fileNameA === '/dev/null') { fileNameA = ''; } @@ -196,14 +229,11 @@ async function* iterSideBySideDiffsFormatted( 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; @@ -213,8 +243,10 @@ async function* iterSideBySideDiffsFormatted( 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); @@ -230,13 +262,13 @@ async function* iterSideBySideDiffsFormatted( 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 " @@@" + 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++) { @@ -252,26 +284,14 @@ async function* iterSideBySideDiffsFormatted( 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. + // 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('+'); 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]; @@ -293,32 +313,20 @@ async function* iterSideBySideDiffsFormatted( } i++; } - // Final part shows the current state, so we just display the - // lines that exist in it without any highlighting. + // Final part: the merge result (current commit state) if (isLineRemoved) { hunkParts[i].lines.push('-' + lineSuffix); + fileDeletions++; } else if (isLineAdded) { hunkParts[i].lines.push('+' + lineSuffix); + fileAdditions++; } else { hunkParts[i].lines.push(' ' + lineSuffix); } - break; } } } - yield* flushPending(); -} - -export async function* iterSideBySideDiffs( - context: Context, - lines: AsyncIterable -) { - for await (const formattedString of iterSideBySideDiffsFormatted( - context, - lines - )) { - yield applyFormatting(context, formattedString); - } + yield* flushPendingEvents(); } diff --git a/src/previewTheme.ts b/src/previewTheme.ts index 298099f..3a16715 100644 --- a/src/previewTheme.ts +++ b/src/previewTheme.ts @@ -12,6 +12,8 @@ const CONFIG = { MIN_LINE_WIDTH: 40, WRAP_LINES: true, HIGHLIGHT_LINE_CHANGES: true, + INTERACTIVE: false, + TREE_WIDTH: 30, }; async function previewTheme( diff --git a/src/themes.ts b/src/themes.ts index ed8ff83..10d8abd 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -46,8 +46,49 @@ 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', + 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', + 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', } +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, + ThemeColorName.FILE_TREE_FILE_SELECTED_COLOR, + ThemeColorName.FILE_TREE_STAGED_COLOR, + ThemeColorName.FILE_TREE_PARTIAL_STAGED_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'], + }, + [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' }, + [ThemeColorName.FILE_TREE_FILE_SELECTED_COLOR]: { backgroundColor: '#3a3a3a' }, + [ThemeColorName.FILE_TREE_STAGED_COLOR]: { color: '#66cc66' }, + [ThemeColorName.FILE_TREE_PARTIAL_STAGED_COLOR]: { color: '#cc9944' }, +}; + export type ThemeDefinition = { SYNTAX_HIGHLIGHTING_THEME?: shiki.BundledTheme; } & { @@ -177,8 +218,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..96c08d2 --- /dev/null +++ b/src/tui/DiffViewPanel.ts @@ -0,0 +1,96 @@ +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; + } + + get viewHeight(): number { + return this.height; + } + + resize(width: number, height: number): void { + this.width = width; + this.height = height; + this.clampScroll(); + } + + setLines(lines: string[]): void { + this.lines = lines; + 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..25fef03 --- /dev/null +++ b/src/tui/FileTreePanel.ts @@ -0,0 +1,461 @@ +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 } + | { type: 'file'; node: FileNode; fileIndex: number }; + +export 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; +} + +export function flattenVisible(nodes: TreeNode[]): VisibleNode[] { + const result: VisibleNode[] = []; + for (const node of nodes) { + if (node.type === 'dir') { + result.push({ type: 'dir', node }); + if (node.expanded) { + result.push(...flattenVisible(node.children)); + } + } else { + result.push({ + type: 'file', + node, + fileIndex: node.fileIndex, + }); + } + } + 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; + 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; + this.rootNodes = buildTree(files); + this.regenerateVisible(); + } + + get viewHeight(): number { + return this.height; + } + + private regenerateVisible(): void { + 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 { + this.width = width; + this.height = height; + this.clampScroll(); + } + + moveUp(): void { + if (this.selectedIndex > 0) { + this.selectedIndex--; + this.ensureVisible(); + } + } + + moveDown(): void { + if (this.selectedIndex < this.visibleNodes.length - 1) { + this.selectedIndex++; + this.ensureVisible(); + } + } + + 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 + } + + /** + * 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.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 { + 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.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 + ): void { + const { + FILE_TREE_COLOR, + FILE_TREE_SELECTED_COLOR, + 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++) { + const visIdx = this.scrollOffset + row; + const screenRow = startRow + row; + + if (visIdx >= this.visibleNodes.length) { + 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 vn = this.visibleNodes[visIdx]; + const isSelected = visIdx === this.selectedIndex; + const indent = ' '.repeat(vn.node.depth); + + 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_DIR_COLOR); + + if (isSelected) { + line.addSpan(0, this.width, FILE_TREE_SELECTED_COLOR); + } + + 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; + + // 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, ' '); + + // 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 + ); + } + + if (stat) { + const statStart = this.width - suffix.length + 1; + 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 + ); + } + } + + // Selected bg before generic base so it wins the bg merge + if (isSelected) { + 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, + applyFormatting(this.context, line) + RESET, + this.width + ); + } + } + } +} diff --git a/src/tui/InputHandler.ts b/src/tui/InputHandler.ts new file mode 100644 index 0000000..6aef9a5 --- /dev/null +++ b/src/tui/InputHandler.ts @@ -0,0 +1,45 @@ +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 ttyFd: number = -1; + private handler: KeyHandler; + + constructor(handler: KeyHandler) { + this.handler = handler; + } + + start(): void { + this.ttyFd = fs.openSync('/dev/tty', 'r'); + this.ttyStream = new tty.ReadStream(this.ttyFd); + 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; + } + if (this.ttyFd >= 0) { + try { fs.closeSync(this.ttyFd); } catch {} + this.ttyFd = -1; + } + } +} diff --git a/src/tui/Screen.ts b/src/tui/Screen.ts new file mode 100644 index 0000000..2c95df7 --- /dev/null +++ b/src/tui/Screen.ts @@ -0,0 +1,129 @@ +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, + ERASE_TO_EOL, + 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(); + } + + clear(): void { + this.buffer += CLEAR_SCREEN; + } + + 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); + this.buffer += ERASE_TO_EOL; + } + + 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..3e7cb8e --- /dev/null +++ b/src/tui/TuiApp.ts @@ -0,0 +1,404 @@ +import { Readable } from 'stream'; +import * as process from 'process'; +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, rerenderDiffLines } from './collectDiffData'; +import { syncTreeToDiff, syncDiffToTree } from './sync'; +import { getGitStagingStatus } from './gitStatus'; +import { RESET } from './ansi'; + +export 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 treeVisible: boolean = true; + private lastDiffWidth: number = 0; + private treeWidth: number = 30; + private resolve!: () => void; + + 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); + + if (this.data.files.length === 0 && this.data.allRenderedLines.length === 0) { + 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; + + this.screen = new Screen(rows, cols); + this.screen.enter(); + + const viewHeight = rows; + const diffWidth = Math.max(1, cols - this.treeWidth - BORDER_WIDTH); + this.lastDiffWidth = diffWidth; + + this.tree = new FileTreePanel( + this.data.files, + this.treeWidth, + 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 treeWidth = this.treeVisible ? this.treeWidth : 0; + const borderWidth = this.treeVisible ? BORDER_WIDTH : 0; + const diffWidth = Math.max(1, cols - treeWidth - borderWidth); + + 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(); + } + + private handleKey(key: string, ctrl: boolean): void { + if (key === 'q' || (ctrl && key === 'c')) { + this.quit(); + return; + } + + if (key === 'e') { + this.treeVisible = !this.treeVisible; + if (!this.treeVisible) { + this.focus = 'diff'; + } + this.handleResize(); + return; + } + + if (key === 'f') { + this.tree.toggleFlatMode(); + this.render(); + return; + } + + if (key === 'tab') { + if (!this.treeVisible) return; + this.focus = this.focus === 'tree' ? 'diff' : 'tree'; + this.render(); + return; + } + + if (this.focus === 'tree') { + this.handleTreeKey(key, ctrl); + } else { + this.handleDiffKey(key, ctrl); + } + } + + 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': + 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': { + 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, + this.data.fileBoundaries + ); + break; + case 'g': + this.tree.selectFirst(); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + case 'G': + this.tree.selectLast(); + syncTreeToDiff( + this.tree, + this.diff, + this.data.fileBoundaries + ); + break; + default: + return; + } + this.render(); + } + + 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': + 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': + if (this.treeVisible) 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 { + this.screen.clear(); + const viewHeight = this.screen.rows; + + if (this.treeVisible) { + const borderCol = this.treeWidth; + const diffCol = this.treeWidth + BORDER_WIDTH; + + this.tree.render(this.screen, 0, 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(); + } + + 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..26b4c0a --- /dev/null +++ b/src/tui/ansi.ts @@ -0,0 +1,24 @@ +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`; +export const ERASE_TO_EOL = `${ESC}[0K`; + +// 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..b15d340 --- /dev/null +++ b/src/tui/collectDiffData.ts @@ -0,0 +1,109 @@ +import { Readable } from 'stream'; +import { Context } from '../context'; +import { applyFormatting } from '../formattedString'; +import { iterlinesFromReadable } from '../iterLinesFromReadable'; +import { iterReplaceTabsWithSpaces } from '../iterReplaceTabsWithSpaces'; +import { + DiffEvent, + iterSideBySideDiffEvents, +} from '../iterSideBySideDiffs'; + +import { StagingStatus } from './gitStatus'; + +export interface DiffFile { + fileNameA: string; + fileNameB: string; + displayName: string; + startLineIndex: number; + additions: number; + deletions: number; + stagingStatus?: StagingStatus; +} + +export interface DiffData { + files: DiffFile[]; + allRenderedLines: string[]; + fileBoundaries: number[]; + rawLines: string[]; +} + +function getDisplayName(fileNameA: string, fileNameB: string): string { + if (!fileNameA) return fileNameB || '(unknown)'; + if (!fileNameB) return fileNameA; + if (fileNameA === fileNameB) return fileNameA; + 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 +): Promise { + const files: DiffFile[] = []; + const allRenderedLines: string[] = []; + const fileBoundaries: number[] = []; + const rawLines: string[] = []; + + const lines = iterReplaceTabsWithSpaces( + context, + iterlinesFromReadable(input) + ); + + // 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, + captureLines() + ); + + 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, + additions: event.additions, + deletions: event.deletions, + }); + fileBoundaries.push(startLineIndex); + } else { + allRenderedLines.push(applyFormatting(context, event.content)); + } + } + + return { files, allRenderedLines, fileBoundaries, rawLines }; +} 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; +} diff --git a/src/tui/gitStatus.ts b/src/tui/gitStatus.ts new file mode 100644 index 0000000..23e3f5c --- /dev/null +++ b/src/tui/gitStatus.ts @@ -0,0 +1,49 @@ +import { execFileSync } 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 = execFileSync('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; +} diff --git a/src/tui/sync.ts b/src/tui/sync.ts new file mode 100644 index 0000000..f4924d3 --- /dev/null +++ b/src/tui/sync.ts @@ -0,0 +1,59 @@ +import { FileTreePanel } from './FileTreePanel'; +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[] +): boolean { + const idx = tree.getSelectedFileIndex(); + if (idx === undefined) return false; + if (idx >= 0 && idx < fileBoundaries.length) { + diff.scrollToLine(fileBoundaries[idx]); + } + return true; +} + +/** + * 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.selectFileByIndex(fileIndex); +} + +/** + * Binary search: find the last boundary <= topLine. + */ +export 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/src/tui/tui.test.ts b/src/tui/tui.test.ts new file mode 100644 index 0000000..6688d16 --- /dev/null +++ b/src/tui/tui.test.ts @@ -0,0 +1,210 @@ +import { truncateAnsi } from './Screen'; +import { getFileIcon } from './fileIcons'; +import { binarySearchBoundary } from './sync'; +import { buildTree, flattenVisible, flattenFlat } 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'); + }); +}); + +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); + }); +}); diff --git a/themes/arctic.json b/themes/arctic.json index 906aa6b..3ca6b36 100644 --- a/themes/arctic.json +++ b/themes/arctic.json @@ -62,5 +62,38 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#2E3440" + }, + "FILE_TREE_COLOR": { + "color": "#D8DEE9", + "backgroundColor": "#232323" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#EBCB8B" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#EBCB8B" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#EBCB8B" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#A3BE8C" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#BF616A" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#A3BE8C" + }, + "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 f0b82ad..0f2895a 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", @@ -63,5 +69,41 @@ "backgroundColor": "#303a30" }, "UNMODIFIED_LINE_COLOR": {}, - "MISSING_LINE_COLOR": {} + "MISSING_LINE_COLOR": {}, + "FILE_TREE_COLOR": { + "color": "#cccccc", + "backgroundColor": "#232323" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#ffdd9966", + "modifiers": [ + "dim" + ] + }, + "FILE_TREE_DIR_COLOR": { + "color": "#ffdd99" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#ffdd99" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#66cc66" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#cc6666" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#66cc66" + }, + "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 a5986d9..715d349 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", @@ -55,5 +57,38 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#2d333b" + }, + "FILE_TREE_COLOR": { + "color": "#adbac7", + "backgroundColor": "#1a2025" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#444c56" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#adbac7" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#539bf5" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#57ab5a" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#e5534b" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#57ab5a" + }, + "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 b5efdbf..8aef5e7 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" @@ -66,5 +68,38 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#fafbfc" + }, + "FILE_TREE_COLOR": { + "color": "#24292e", + "backgroundColor": "#f7f7f7" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#e1e4e8" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#05264c" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#0366d6" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#28a745" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#d73a49" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#28a745" + }, + "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 5ea8fcc..faf2609 100644 --- a/themes/light.json +++ b/themes/light.json @@ -60,5 +60,38 @@ "UNMODIFIED_LINE_COLOR": {}, "MISSING_LINE_COLOR": { "backgroundColor": "#e8e8e8" + }, + "FILE_TREE_COLOR": { + "color": "#333333", + "backgroundColor": "#e6e6e6" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#cccccc" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#336666" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#336666" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#228822" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#aa3333" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#228822" + }, + "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 1042d45..deb3949 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": { @@ -55,5 +59,38 @@ "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" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#ffffff" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#ffffff" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#cccccc" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#888888" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#66cc66" + }, + "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 780f737..73ed3e9 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": { @@ -53,5 +57,36 @@ "INSERTED_WORD_COLOR": { "color": "#dddddd", "backgroundColor": "#666666" + }, + "FILE_TREE_COLOR": { + "color": "#333333", + "backgroundColor": "#d5d5d5" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": {}, + "FILE_TREE_DIR_COLOR": { + "color": "#000000" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#000000" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#333333" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#888888" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#228822" + }, + "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 7c7958a..ce67aa7 100644 --- a/themes/solarized-dark.json +++ b/themes/solarized-dark.json @@ -53,5 +53,38 @@ "backgroundColor": "#003b36" }, "UNMODIFIED_LINE_COLOR": {}, - "MISSING_LINE_COLOR": {} + "MISSING_LINE_COLOR": {}, + "FILE_TREE_COLOR": { + "color": "#839496", + "backgroundColor": "#00232e" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#93a1a1" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#93a1a1" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#b58900" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#859900" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#dc322f" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#859900" + }, + "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 b1a3a62..1a7915a 100644 --- a/themes/solarized-light.json +++ b/themes/solarized-light.json @@ -55,5 +55,38 @@ "backgroundColor": "#edf6d3" }, "UNMODIFIED_LINE_COLOR": {}, - "MISSING_LINE_COLOR": {} + "MISSING_LINE_COLOR": {}, + "FILE_TREE_COLOR": { + "color": "#657b83", + "backgroundColor": "#f5eedb" + }, + "FILE_TREE_SELECTED_COLOR": { + "modifiers": [ + "inverse" + ] + }, + "FILE_TREE_BORDER_COLOR": { + "color": "#586e75" + }, + "FILE_TREE_DIR_COLOR": { + "color": "#586e75" + }, + "FILE_TREE_BORDER_FOCUSED_COLOR": { + "color": "#b58900" + }, + "FILE_TREE_ADDITIONS_COLOR": { + "color": "#859900" + }, + "FILE_TREE_DELETIONS_COLOR": { + "color": "#dc322f" + }, + "FILE_TREE_STAGED_COLOR": { + "color": "#859900" + }, + "FILE_TREE_PARTIAL_STAGED_COLOR": { + "color": "#cb4b16" + }, + "FILE_TREE_FILE_SELECTED_COLOR": { + "backgroundColor": "#e5ddc8" + } }